4 Commits

Author SHA1 Message Date
Claude e6b79ce2c2 Add bulk actions, multi-currency, expenses, templates, and reports
Schema (migration 0001):
- clients: add currency column (default USD)
- invoices: add currency column (default USD)
- New expenses table: amount, currency, category, billable, reimbursable,
  client/invoice/business relations, notes
- New invoice_templates table: name, type (notes|terms), content, isDefault

API:
- invoices: add bulkUpdateStatus and bulkDelete procedures (ownership-safe)
- invoices: currency field threaded through create/update schemas
- clients: currency field added to create/update schemas
- New expenses router: full CRUD with authorization
- New invoiceTemplates router: full CRUD, isDefault management per type
- Root router: wire in expenses and invoiceTemplates

Currency (src/lib/currency.ts):
- Shared formatCurrency(amount, currency) utility replacing hardcoded USD
- SUPPORTED_CURRENCIES list (17 currencies)
- Invoice form: currency selector in Config card, auto-fills from client
- Client form: currency selector in Billing Information card

Bulk actions (invoices list):
- Checkbox column with select-all support
- Selection toolbar: Mark as Sent/Paid/Draft dropdown, Delete (N) button
- DataTable: new selectionActions prop renders toolbar when rows selected

Notes templates:
- Invoice form: Notes card with textarea in Details tab
- Template dropdown button appears when templates exist
- /dashboard/invoices/templates: full CRUD page for notes and terms templates

New pages:
- /dashboard/expenses: expense list with summary cards, add/edit dialog
- /dashboard/reports: KPI cards, 12-month revenue area chart, top clients
  bar chart, status breakdown, recent activity
- Navigation: Expenses and Reports added to Main section

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 02:34:06 +00:00
Claude ba14526fc5 Set up proper DB migrations and fix remaining mobile responsive issues
Migrations:
- drizzle.config.ts: add out: './drizzle' so drizzle-kit generate writes
  SQL migration files instead of only supporting push
- drizzle/0000_glossy_magneto.sql: initial migration capturing all 9
  current tables (users, accounts, sessions, verification_tokens,
  sso_providers, clients, businesses, invoices, invoice_items)
- src/server/db/migrate.ts: programmatic runner using drizzle-orm's
  migrate() — tracks applied migrations in __drizzle_migrations,
  safe to run on every deploy
- package.json: db:migrate now runs the programmatic runner instead of
  drizzle-kit migrate (CLI requires devDeps at runtime)
- start.sh: replace drizzle-kit push with bun src/server/db/migrate.ts
- Dockerfile: copy drizzle/ folder into the runner image so migrations
  are available at container startup

Mobile fixes:
- data-table.tsx: pagination buttons grow from 32px to 40px on mobile
  (h-10 w-10 md:h-8 md:w-8) to meet 44px touch-target guidelines
- floating-action-bar.tsx: stack left-content + action buttons to column
  layout on narrow screens (flex-col sm:flex-row), reduce padding on
  mobile (p-3 sm:p-4)
- revenue-chart.tsx: responsive chart height (h-48 md:h-64) so the chart
  doesn't consume too much vertical space on small screens

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 01:59:08 +00:00
Claude 563d77ba65 Update README and improve mobile responsiveness for invoicing UI
- README: fix auth (better-auth), database (PostgreSQL), env vars,
  Docker setup, and feature list to reflect actual implementation
- InvoicesDataTable: show status badge + amount inline on mobile
  (previously hidden behind sm: breakpoint, leaving mobile users
  with no financial or status info at a glance)
- InvoiceItemsTable: hide Date/Hours/Rate columns on mobile and
  fold that info into the Description cell as secondary text
- invoice-view.tsx header card: wrap to column layout on mobile
  so status/amount/button don't overflow narrow screens; also
  improve item rows to show date, hours, and rate as subtext

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 01:53:15 +00:00
soconnor fb5ffc3195 upd: upgrade dependencies and improve invoice form layout 2026-04-04 21:09:24 -04:00
33 changed files with 4948 additions and 333 deletions
+1
View File
@@ -44,6 +44,7 @@ RUN bun install --frozen-lockfile --production --verbose
COPY --from=builder /app/start.sh ./start.sh
COPY --from=builder /app/next.config.js ./next.config.js
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/.env.example ./.env.example
+132 -66
View File
@@ -8,23 +8,29 @@ A modern, professional invoicing application built for freelancers and small bus
## ✨ 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
- **🏢 Business Profiles** - Manage your business details, logo, and email settings
- **📄 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
- **📱 Responsive Design** - Works seamlessly on desktop, tablet, and mobile
- **🎨 Modern UI** - Clean, professional interface built with shadcn/ui
- **⚡ Type-Safe** - Full TypeScript support with tRPC for API calls
- **💾 Local Database** - SQLite database with Drizzle ORM
- **💾 PostgreSQL Database** - Robust relational database with Drizzle ORM
## 🚀 Tech Stack
- **Frontend**: Next.js 15 with App Router
- **Frontend**: Next.js 16 with App Router
- **Backend**: tRPC for type-safe API calls
- **Database**: Drizzle ORM with LibSQL (SQLite)
- **Authentication**: NextAuth.js with email/password
- **UI Components**: shadcn/ui with Tailwind CSS
- **Styling**: Geist font family
- **Database**: Drizzle ORM with PostgreSQL
- **Authentication**: better-auth with email/password and Authentik OIDC SSO
- **UI Components**: shadcn/ui with Tailwind CSS v4
- **Email**: Resend for transactional email delivery
- **PDF**: @react-pdf/renderer for invoice PDF generation
- **Package Manager**: Bun
## 📦 Installation
@@ -32,6 +38,7 @@ A modern, professional invoicing application built for freelancers and small bus
### Prerequisites
- Node.js 18+ or Bun
- Docker & Docker Compose (for local PostgreSQL)
- Git
### Quick Start
@@ -43,7 +50,6 @@ A modern, professional invoicing application built for freelancers and small bus
```
2. **Install dependencies**
```bash
```bash
bun install
```
@@ -55,22 +61,39 @@ A modern, professional invoicing application built for freelancers and small bus
Edit `.env.local` and add your configuration:
```env
DATABASE_URL="file:./db.sqlite"
NEXTAUTH_SECRET="your-secret-key-here"
NEXTAUTH_URL="http://localhost:3000"
# Database
DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice"
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
bun run db:push
```
5. **Start the development server**
6. **Start the development server**
```bash
bun run dev
```
6. **Open your browser**
7. **Open your browser**
Navigate to [http://localhost:3000](http://localhost:3000)
## 🏗️ Project Structure
@@ -79,21 +102,28 @@ A modern, professional invoicing application built for freelancers and small bus
beenvoice/
├── src/
│ ├── app/ # Next.js App Router pages
│ │ ├── api/ # API routes (NextAuth, tRPC)
│ │ ├── api/ # API routes (better-auth, tRPC)
│ │ ├── auth/ # Authentication pages
│ │ ├── clients/ # Client management pages
│ │ ├── invoices/ # Invoice management pages
│ │ ├── dashboard/ # Main app pages
│ │ │ ├── clients/ # Client management pages
│ │ │ ├── invoices/ # Invoice management pages
│ │ │ └── businesses/ # Business profile pages
│ │ └── _components/ # Page-specific components
│ ├── components/ # Shared UI components
│ │ ├── ui/ # shadcn/ui components
│ │ ├── data/ # Data display components
│ │ ├── forms/ # Form components
│ │ └── layout/ # Layout components
│ ├── server/ # Server-side code
│ │ ├── api/ # tRPC routers
│ │ ├── auth/ # NextAuth configuration
│ │ └── db/ # Database schema and connection
│ ├── lib/ # Utilities (auth, pdf export, etc.)
│ ├── styles/ # Global styles
│ └── trpc/ # tRPC client configuration
├── drizzle/ # Database migrations
├── public/ # Static assets
── docs/ # Documentation
── docs/ # Documentation
└── docker-compose.yml # Local PostgreSQL setup
```
## 🎯 Usage
@@ -103,41 +133,53 @@ beenvoice/
1. **Register an Account**
- Visit the sign-up page
- Enter your name, email, and password
- Verify your email (if configured)
2. **Add Your First Client**
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
- Click "Add New Client"
- Fill in client details (name, email, phone, address)
3. **Create an Invoice**
4. **Create an Invoice**
- Go to the Invoices page
- Click "Create New Invoice"
- Select a client
- Select a client and optionally a business profile
- 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
#### Client Management
- Create and edit client profiles
- Store contact information and addresses
- Set default hourly rates per client
- Search and filter client list
- View client history
#### Invoice Creation
- Select from existing clients
- Add multiple line items
- Select from existing clients and business profiles
- Add multiple line items with drag-and-drop reordering
- 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
#### Invoice Delivery
- Send invoices via email directly from the app
- Rich text email composer with preview
- Resend and re-deliver sent invoices
- Track invoice status: Draft → Sent → Paid (+ Overdue)
#### User Interface
- Clean, modern design
- Responsive layout
- Intuitive navigation
- Fully responsive — desktop, tablet, and mobile
- Intuitive navigation with breadcrumbs
- Toast notifications for feedback
- Modal dialogs for forms
- Dark mode support
## 🔧 Development
@@ -145,44 +187,53 @@ beenvoice/
```bash
# Development
bun run dev # Start development server
bun run dev # Start development server (Turbo)
bun run build # Build for production
bun run start # Start production server
# Database
bun run db:push # Push schema changes to database
bun run db:migrate # Run migrations
bun run db:studio # Open Drizzle Studio
bun run db:generate # Generate new migration
# Docker
bun run docker:up # Start local PostgreSQL via Docker
bun run docker:down # Stop Docker services
# Code Quality
bun run lint # Run ESLint
bun run format # Format code with Prettier
bun run type-check # Run TypeScript type checking
bun run lint:fix # Fix ESLint issues
bun run format:write # Format code with Prettier
bun run typecheck # Run TypeScript type checking
```
### Database Schema
The application uses four main tables:
The application uses the following core tables:
- **users**: User accounts and authentication
- **clients**: Client information and contact details
- **invoices**: Invoice headers with client relationships
- **invoice_items**: Individual line items with pricing
- **users** - User accounts and authentication
- **sessions** - Active user sessions
- **clients** - Client information and contact details
- **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
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
- **Invoices**: Invoice creation and management
- **Businesses**: Business profile management
- **Invoices**: Invoice creation, management, and status tracking
- **Validation**: Zod schemas for input validation
## 🎨 Customization
### 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)
- **Font**: Geist for professional typography
@@ -198,38 +249,54 @@ Update the logo and colors in:
## 🚀 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:
```bash
bun run build
```
2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production)
2. Start the server:
```bash
bun start
```
3. **Run database migrations:**
```bash
bun run db:push
```
### Other Platforms
The app can be deployed to any platform that supports Next.js:
- **Netlify**: Use the Next.js build command
- **Railway**: Connect your GitHub repository
- **DigitalOcean App Platform**: Deploy with automatic scaling
4. **Start the server:**
```bash
bun start
```
### Environment Variables
Required for production:
```env
DATABASE_URL="your-database-url"
NEXTAUTH_SECRET="your-secret-key"
NEXTAUTH_URL="https://your-domain.com"
DATABASE_URL="postgresql://user:password@host:5432/dbname"
AUTH_SECRET="your-long-random-secret"
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
1. Fork the repository
@@ -243,8 +310,7 @@ NEXTAUTH_URL="https://your-domain.com"
- Follow TypeScript best practices
- Use shadcn/ui components for consistency
- Implement proper error handling
- Add tests for new features
- Follow the existing code style
- Follow the existing code style (Prettier + ESLint configs provided)
## 📄 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
- [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
- [Resend](https://resend.com/) for reliable email delivery
## 📞 Support
- **Issues**: [GitHub Issues](https://github.com/yourusername/beenvoice/issues)
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/beenvoice/discussions)
- **Email**: support@beenvoice.com
---
+17 -15
View File
@@ -49,11 +49,11 @@
"file-saver": "^2.0.5",
"framer-motion": "^12.23.26",
"lucide-react": "^0.525.0",
"next": "^16.1.1",
"next": "^16.2.2",
"pg": "8.13.1",
"react": "^19.2.3",
"react": "^19.2.4",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.3",
"react-dom": "^19.2.4",
"react-dropzone": "^14.3.8",
"recharts": "^3.5.1",
"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=="],
"@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/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=="],
@@ -1238,7 +1238,7 @@
"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=="],
@@ -1376,11 +1376,11 @@
"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-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=="],
@@ -1720,6 +1720,8 @@
"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=="],
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
+1
View File
@@ -14,6 +14,7 @@ if (!process.env.DATABASE_URL) {
export default {
schema: "./src/server/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,
+166
View File
@@ -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");
+43
View File
@@ -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");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"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
}
]
}
+4 -4
View File
@@ -7,7 +7,7 @@
"build": "next build",
"check": "eslint . && tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:migrate": "bun src/server/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:clone": "./scripts/clone-local.sh",
@@ -68,11 +68,11 @@
"file-saver": "^2.0.5",
"framer-motion": "^12.23.26",
"lucide-react": "^0.525.0",
"next": "^16.1.1",
"next": "^16.2.2",
"pg": "8.13.1",
"react": "^19.2.3",
"react": "^19.2.4",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.3",
"react-dom": "^19.2.4",
"react-dropzone": "^14.3.8",
"recharts": "^3.5.1",
"resend": "^4.8.0",
@@ -86,7 +86,7 @@ export function RevenueChart({ data }: RevenueChartProps) {
}
return (
<div className="h-64 w-full">
<div className="h-48 w-full md:h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
+273
View File
@@ -0,0 +1,273 @@
"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 "~/server/api/routers/expenses";
interface ExpenseFormData {
date: Date;
description: string;
amount: number;
currency: string;
category: string;
billable: boolean;
reimbursable: boolean;
notes: string;
clientId: string;
}
const defaultForm: ExpenseFormData = {
date: new Date(),
description: "",
amount: 0,
currency: "USD",
category: "",
billable: false,
reimbursable: 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,
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 };
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);
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-3">
<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 className="col-span-2 sm:col-span-1">
<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.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 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>
</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",
header: "Date",
cell: ({ row }) => formatDate(row.getValue("date")),
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<div className="font-medium">{row.getValue("description")}</div>
),
cell: ({ row }) => {
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)} &middot; {item.hours}h @ {formatCurrency(item.rate)}/hr
</p>
</div>
</>
);
},
},
{
accessorKey: "hours",
@@ -54,6 +73,10 @@ const columns: ColumnDef<InvoiceItem>[] = [
cell: ({ row }) => (
<div className="text-right">{row.getValue("hours")}</div>
),
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
},
{
accessorKey: "rate",
@@ -61,6 +84,10 @@ const columns: ColumnDef<InvoiceItem>[] = [
cell: ({ row }) => (
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
),
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
},
{
accessorKey: "amount",
@@ -3,7 +3,8 @@
import { useState } from "react";
import Link from "next/link";
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 { StatusBadge, type StatusType } from "~/components/data/status-badge";
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
@@ -16,13 +17,19 @@ import {
DialogHeader,
DialogTitle,
} 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 { toast } from "sonner";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { formatCurrency } from "~/lib/currency";
import type { StoredInvoiceStatus } from "~/types/invoice";
// Type for invoice data
interface Invoice {
id: string;
invoiceNumber: string;
@@ -33,32 +40,16 @@ interface Invoice {
status: string;
totalAmount: number;
taxRate: number;
currency: string;
notes: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date | null;
client?: {
id: string;
name: string;
email: string | null;
phone: string | null;
} | null;
business?: {
id: string;
name: string;
email: string | null;
phone: string | null;
} | null;
client?: { id: string; name: string; email: string | null; phone: string | null } | null;
business?: { id: string; name: string; email: string | null; phone: string | null } | null;
items?: Array<{
id: string;
invoiceId: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
position: number;
createdAt: Date;
id: string; invoiceId: string; date: Date; description: string;
hours: number; rate: number; amount: number; position: number; createdAt: Date;
}> | null;
}
@@ -66,67 +57,74 @@ interface InvoicesDataTableProps {
invoices: Invoice[];
}
const getStatusType = (invoice: Invoice): StatusType => {
return getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) as StatusType;
};
const getStatusType = (invoice: Invoice): StatusType =>
getEffectiveInvoiceStatus(invoice.status as StoredInvoiceStatus, invoice.dueDate) as StatusType;
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
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);
};
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { month: "short", day: "2-digit", year: "numeric" }).format(new Date(date));
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [pendingBulkDelete, setPendingBulkDelete] = useState<Invoice[]>([]);
const utils = api.useUtils();
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
toast.success("Invoice deleted successfully");
toast.success("Invoice deleted");
void utils.invoices.getAll.invalidate();
setDeleteDialogOpen(false);
setInvoiceToDelete(null);
},
onError: (error) => {
toast.error(error.message ?? "Failed to delete invoice");
},
onError: (e) => toast.error(e.message ?? "Failed to delete invoice"),
});
const handleRowClick = (invoice: Invoice) => {
router.push(`/dashboard/invoices/${invoice.id}`);
};
const bulkDelete = api.invoices.bulkDelete.useMutation({
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) => {
setInvoiceToDelete(invoice);
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (invoiceToDelete) {
deleteInvoice.mutate({ id: invoiceToDelete.id });
}
};
const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({
onSuccess: (data) => {
toast.success(`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`);
void utils.invoices.getAll.invalidate();
},
onError: (e) => toast.error(e.message ?? "Failed to update invoices"),
});
const columns: ColumnDef<Invoice>[] = [
{
accessorKey: "client.name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Client" />
id: "select",
header: ({ table }) => (
<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 }) => {
const invoice = row.original;
return (
@@ -134,13 +132,15 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<div className="bg-primary/10 hidden p-2 sm:flex">
<FileText className="text-primary h-4 w-4" />
</div>
<div className="max-w-[80px] min-w-0 sm:max-w-[200px] lg:max-w-[300px]">
<p className="truncate font-medium">
{invoice.client?.name ?? "—"}
</p>
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{invoice.invoiceNumber}
</p>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{invoice.client?.name ?? "—"}</p>
<p className="text-muted-foreground truncate text-xs sm:text-sm">{invoice.invoiceNumber}</p>
<div className="mt-1 flex items-center gap-2 sm:hidden">
<StatusBadge status={getStatusType(invoice)} className="text-xs" />
<span className="text-foreground text-xs font-semibold">
{formatCurrency(invoice.totalAmount, invoice.currency)}
</span>
</div>
</div>
</div>
);
@@ -148,69 +148,38 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
},
{
accessorKey: "issueDate",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Date" />
header: ({ column }) => <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",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => (
<StatusBadge
status={getStatusType(row.original)}
className={getStatusType(row.original) === "sent" ? "status-pending" : ""}
/>
),
cell: ({ row }) => {
const invoice = row.original;
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",
},
filterFn: (row, _id, value: string[]) => value.includes(getStatusType(row.original)),
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" },
},
{
accessorKey: "totalAmount",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Amount" />
header: ({ column }) => <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 }) => {
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",
},
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" },
},
{
id: "actions",
@@ -219,33 +188,19 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
return (
<div className="flex items-center justify-end gap-1">
<Link href={`/dashboard/invoices/${invoice.id}`}>
<Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
>
<Button 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" />
</Button>
</Link>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
>
<Button 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" />
</Button>
</Link>
<Button
variant="ghost"
size="sm"
variant="ghost" size="sm"
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
handleDelete(invoice);
}}
onClick={(e) => { e.stopPropagation(); setInvoiceToDelete(invoice); setDeleteDialogOpen(true); }}
data-action-button="true"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -282,10 +237,68 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
searchKey="invoiceNumber"
searchPlaceholder="Search invoices..."
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}>
<DialogContent>
<DialogHeader>
@@ -293,21 +306,16 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<DialogDescription>
Are you sure you want to delete invoice{" "}
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
<strong>{invoiceToDelete?.client?.name}</strong>? This action
cannot be undone.
<strong>{invoiceToDelete?.client?.name}</strong>? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={deleteInvoice.isPending}
>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleteInvoice.isPending}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
onClick={() => invoiceToDelete && deleteInvoice.mutate({ id: invoiceToDelete.id })}
disabled={deleteInvoice.isPending}
>
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
@@ -315,6 +323,31 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
</DialogFooter>
</DialogContent>
</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>
);
}
+261
View File
@@ -0,0 +1,261 @@
"use client";
import { useMemo } 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 { 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 } from "lucide-react";
export default function ReportsPage() {
const { data: invoices = [], isLoading } = api.invoices.getAll.useQuery();
const { data: stats } = api.dashboard.getStats.useQuery();
const now = new Date();
const reportData = useMemo(() => {
if (!invoices.length) return null;
// Revenue by month (last 12 months)
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;
let overdueCount = 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;
}
if (status === "overdue") overdueCount++;
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,
}));
// Top clients by revenue (paid only)
const clientMap: Record<string, { name: string; revenue: number; count: 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, count: 0 };
clientMap[id]!.revenue += inv.totalAmount;
clientMap[id]!.count += 1;
}
}
const topClients = Object.values(clientMap)
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 6);
// Status breakdown
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, overdueCount, statusCount };
}, [invoices]);
if (isLoading) {
return (
<div className="page-enter space-y-6">
<PageHeader title="Reports" description="Revenue and invoice 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>
);
}
const avgInvoice = invoices.length > 0 ? (reportData?.totalRevenue ?? 0) / invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 0 : 0;
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader title="Reports" description="Revenue and invoice analytics" variant="gradient" />
{/* KPI cards */}
<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(reportData?.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(reportData?.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">{(reportData?.totalHours ?? 0).toFixed(1)}h</p>
</CardContent>
</Card>
</div>
{/* Revenue trend chart */}
<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={reportData?.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">
{/* Top clients */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" /> Top Clients by Revenue
</CardTitle>
</CardHeader>
<CardContent>
{!reportData?.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={reportData.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>
{/* Invoice status breakdown */}
<Card>
<CardHeader>
<CardTitle>Invoice Status Breakdown</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Object.entries(reportData?.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>
{/* Monthly stats table */}
{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>
)}
</div>
);
}
+27 -4
View File
@@ -72,6 +72,11 @@ interface DataTableProps<TData, TValue> {
options: { label: string; value: string }[];
}[];
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>({
@@ -89,6 +94,7 @@ export function DataTable<TData, TValue>({
actions,
filterableColumns = [],
onRowClick,
selectionActions,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
@@ -335,6 +341,23 @@ export function DataTable<TData, TValue>({
</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 */}
<Card className="bg-card border-border overflow-hidden border p-0">
<div className="w-full overflow-x-auto">
@@ -471,7 +494,7 @@ export function DataTable<TData, TValue>({
<Button
variant="outline"
size="icon"
className="h-8 w-8"
className="h-10 w-10 md:h-8 md:w-8"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
@@ -481,7 +504,7 @@ export function DataTable<TData, TValue>({
<Button
variant="outline"
size="icon"
className="h-8 w-8"
className="h-10 w-10 md:h-8 md:w-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
@@ -503,7 +526,7 @@ export function DataTable<TData, TValue>({
<Button
variant="outline"
size="icon"
className="h-8 w-8"
className="h-10 w-10 md:h-8 md:w-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
@@ -513,7 +536,7 @@ export function DataTable<TData, TValue>({
<Button
variant="outline"
size="icon"
className="h-8 w-8"
className="h-10 w-10 md:h-8 md:w-8"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
+25 -22
View File
@@ -185,14 +185,14 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
{/* Invoice Header Card */}
<Card className="bg-card border-border border">
<CardContent>
<div className="flex items-start justify-between">
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1 space-y-4">
<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" />
</div>
<div>
<h2 className="text-foreground text-2xl font-bold">
<div className="min-w-0">
<h2 className="text-foreground truncate text-2xl font-bold">
{invoice.invoiceNumber}
</h2>
<p className="text-muted-foreground">
@@ -217,21 +217,23 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
</div>
</div>
<div className="space-y-3 text-right">
<StatusBadge
status={invoice.status as StatusType}
className="px-3 py-1 text-sm font-medium"
>
<StatusIcon className="mr-1 h-3 w-3" />
</StatusBadge>
<div className="text-primary text-3xl font-bold">
{formatCurrency(invoice.totalAmount)}
<div className="flex flex-row items-center justify-between gap-3 sm:flex-col sm:items-end sm:text-right">
<div>
<StatusBadge
status={invoice.status as StatusType}
className="px-3 py-1 text-sm font-medium"
>
<StatusIcon className="mr-1 h-3 w-3" />
</StatusBadge>
<div className="text-primary mt-1 text-2xl font-bold sm:text-3xl">
{formatCurrency(invoice.totalAmount)}
</div>
</div>
<Button
onClick={handlePDFExport}
disabled={isExportingPDF}
variant="default"
className="transform-none"
className="transform-none flex-shrink-0"
>
{isExportingPDF ? (
<>
@@ -326,17 +328,18 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
{invoice.items?.map((item, index) => (
<div
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="text-muted-foreground text-sm">
{formatDate(item.date)}
</div>
<div className="text-foreground font-medium">
<div className="min-w-0 flex-1">
<div className="text-foreground font-medium break-words">
{item.description}
</div>
<div className="text-muted-foreground mt-0.5 text-sm">
{formatDate(item.date)} &middot; {item.hours}h @{" "}
{formatCurrency(item.rate)}/hr
</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)}
</div>
</div>
+35
View File
@@ -28,6 +28,14 @@ import {
VALIDATION_MESSAGES,
PLACEHOLDERS,
} from "~/lib/form-constants";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
interface ClientFormProps {
clientId?: string;
@@ -45,6 +53,7 @@ interface FormData {
postalCode: string;
country: string;
defaultHourlyRate: number | null;
currency: string;
}
interface FormErrors {
@@ -70,6 +79,7 @@ const initialFormData: FormData = {
postalCode: "",
country: "United States",
defaultHourlyRate: null,
currency: "USD",
};
export function ClientForm({ clientId, mode }: ClientFormProps) {
@@ -120,6 +130,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
postalCode: client.postalCode ?? "",
country: client.country ?? "United States",
defaultHourlyRate: client.defaultHourlyRate ?? null,
currency: client.currency ?? "USD",
});
}
}, [client, mode]);
@@ -468,6 +479,30 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</p>
)}
</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>
</Card>
</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",
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
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) => {
const isSelected = date && isSameDay(day, date);
const isToday = isSameDay(day, new Date());
@@ -241,7 +241,7 @@ export function InvoiceCalendarView({
type="button"
onClick={() => handleSelectDate(day)}
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",
isToday && !isSelected ? "bg-accent/40" : ""
)}
@@ -283,7 +283,7 @@ export function InvoiceCalendarView({
open={sheetOpen}
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">
<SheetTitle className="flex items-center gap-3 text-2xl flex-wrap">
<div className="bg-primary/10 p-2.5 rounded-full flex-shrink-0">
+97 -30
View File
@@ -21,7 +21,15 @@ import { InvoiceLineItems } from "./invoice-line-items";
import { InvoiceCalendarView } from "./invoice-calendar-view";
import { api } from "~/trpc/react";
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 {
Dialog,
DialogContent,
@@ -71,6 +79,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
status: "draft",
notes: "",
taxRate: 0,
currency: "USD",
defaultHourlyRate: null,
items: [
{
@@ -92,6 +101,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
// Queries (Same as before)
const { data: clients, isLoading: loadingClients } =
api.clients.getAll.useQuery();
const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({ type: "notes" });
const { data: businesses, isLoading: loadingBusinesses } =
api.businesses.getAll.useQuery();
const { data: existingInvoice, isLoading: loadingInvoice } =
@@ -137,6 +147,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
status: existingInvoice.status as "draft" | "sent" | "paid",
notes: existingInvoice.notes ?? "",
taxRate: existingInvoice.taxRate,
currency: existingInvoice.currency ?? "USD",
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
items:
mappedItems.length > 0
@@ -329,6 +340,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
status: formData.status,
notes: formData.notes,
taxRate: formData.taxRate,
currency: formData.currency,
items: formData.items
.sort(
(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}
onValueChange={(v) => {
updateField("clientId", v);
// Auto-fill Hourly Rate
const selectedClient = clients?.find((c) => c.id === v);
const currentBusiness = businesses?.find(
(b) => b.id === formData.businessId,
);
// Explicitly prioritize client rate, then business rate, then 0
const clientRate =
selectedClient && "defaultHourlyRate" in selectedClient
? selectedClient.defaultHourlyRate
: null;
const businessRate =
currentBusiness &&
"defaultHourlyRate" in currentBusiness
currentBusiness && "defaultHourlyRate" in currentBusiness
? currentBusiness.defaultHourlyRate
: null;
const rateToSet: number = (clientRate ??
businessRate ??
0) as number;
updateField("defaultHourlyRate", rateToSet);
updateField("defaultHourlyRate", (clientRate ?? businessRate ?? 0) as number);
// Auto-fill currency from client
if (selectedClient && "currency" in selectedClient && selectedClient.currency) {
updateField("currency", selectedClient.currency as string);
}
}}
>
<SelectTrigger className="w-full">
@@ -494,7 +503,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardTitle>
</CardHeader>
<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">
<Label>Issue Date</Label>
<DatePicker
@@ -537,28 +546,86 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</div>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select
value={formData.status}
onValueChange={(v: "draft" | "sent" | "paid") =>
updateField("status", v)
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Status</Label>
<Select
value={formData.status}
onValueChange={(v: "draft" | "sent" | "paid") =>
updateField("status", v)
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</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>
</CardContent>
</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>
{/* ITEMS TAB */}
+1
View File
@@ -21,6 +21,7 @@ export interface InvoiceFormData {
status: "draft" | "sent" | "paid";
notes: string;
taxRate: number;
currency: string;
defaultHourlyRate: number | null;
items: InvoiceItem[];
}
@@ -72,7 +72,7 @@ export function FloatingActionBar({
)}
>
<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 */}
{leftContent && (
<div className="text-card-foreground animate-fade-in flex flex-1 items-center gap-3">
+30
View File
@@ -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);
}
+4
View File
@@ -4,6 +4,8 @@ import {
Users,
FileText,
Building,
Receipt,
BarChart2,
} from "lucide-react";
export interface NavLink {
@@ -25,6 +27,8 @@ export const navigationConfig: NavSection[] = [
{ name: "Clients", href: "/dashboard/clients", icon: Users },
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
],
},
{
+4
View File
@@ -4,6 +4,8 @@ import { invoicesRouter } from "~/server/api/routers/invoices";
import { settingsRouter } from "~/server/api/routers/settings";
import { emailRouter } from "~/server/api/routers/email";
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";
/**
@@ -18,6 +20,8 @@ export const appRouter = createTRPCRouter({
settings: settingsRouter,
email: emailRouter,
dashboard: dashboardRouter,
expenses: expensesRouter,
invoiceTemplates: invoiceTemplatesRouter,
});
// export type definition of API
+1
View File
@@ -43,6 +43,7 @@ const createClientSchema = z.object({
.optional()
.or(z.literal("")),
defaultHourlyRate: z.number().min(0, "Rate must be positive").optional(),
currency: z.string().length(3).default("USD").optional(),
});
const updateClientSchema = createClientSchema.partial().extend({
+163
View File
@@ -0,0 +1,163 @@
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";
export const EXPENSE_CATEGORIES = [
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
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),
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 };
}),
});
+120
View File
@@ -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 };
}),
});
+56 -26
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import {
invoices,
@@ -29,6 +29,7 @@ const createInvoiceSchema = z.object({
status: z.enum(["draft", "sent", "paid"]).default("draft"),
notes: z.string().optional().or(z.literal("")),
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"),
});
@@ -410,47 +411,76 @@ export const invoicesRouter = createTRPCRouter({
.input(updateStatusSchema)
.mutation(async ({ ctx, input }) => {
try {
// Verify invoice exists and belongs to user
const invoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.id, input.id),
});
if (!invoice) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invoice not found",
});
throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" });
}
if (invoice.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to update this invoice",
});
throw new TRPCError({ code: "FORBIDDEN", message: "You don't have permission to update this invoice" });
}
await ctx.db
.update(invoices)
.set({
status: input.status,
updatedAt: new Date(),
})
.set({ status: input.status, updatedAt: new Date() })
.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) {
console.error("UpdateStatus error:", error);
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update invoice status",
cause: error,
});
throw new TRPCError({ 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 };
}),
});
+49
View File
@@ -0,0 +1,49 @@
/**
* 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.
*/
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 { 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);
console.log("[migrate] Running migrations from", migrationsFolder);
try {
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();
}
+103 -1
View File
@@ -39,7 +39,9 @@ export const usersRelations = relations(users, ({ many }) => ({
clients: many(clients),
businesses: many(businesses),
invoices: many(invoices),
sessions: many(sessions), // Added missing relation
sessions: many(sessions),
expenses: many(expenses),
invoiceTemplates: many(invoiceTemplates),
}));
export const accounts = createTable(
@@ -140,6 +142,7 @@ export const clients = createTable(
postalCode: d.varchar({ length: 20 }),
country: d.varchar({ length: 100 }),
defaultHourlyRate: d.real(),
currency: d.varchar({ length: 3 }).default("USD").notNull(),
createdById: d
.varchar({ length: 255 })
.notNull()
@@ -238,6 +241,7 @@ export const invoices = createTable(
totalAmount: d.real().notNull().default(0),
taxRate: d.real().notNull().default(0.0),
notes: d.varchar({ length: 1000 }),
currency: d.varchar({ length: 3 }).default("USD").notNull(),
createdById: d
.varchar({ length: 255 })
.notNull()
@@ -309,3 +313,101 @@ export const invoiceItemsRelations = relations(invoiceItems, ({ one }) => ({
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(),
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],
}),
}),
);
+2 -3
View File
@@ -108,9 +108,8 @@ fi
SKIP_DB_MIGRATION=${SKIP_DB_MIGRATION:-false}
if [ "$SKIP_DB_MIGRATION" != "true" ]; then
echo "[start.sh] Applying database migrations (drizzle-kit push via bunx)"
# Use bunx so we don't need devDependencies inside the container
SKIP_ENV_VALIDATION=1 bunx -y drizzle-kit@0.30.6 push
echo "[start.sh] Applying database migrations"
SKIP_ENV_VALIDATION=1 bun src/server/db/migrate.ts
else
echo "[start.sh] Skipping DB migration due to SKIP_DB_MIGRATION=${SKIP_DB_MIGRATION}"
fi