mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 17:48:55 -04:00
Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddc2b42672 | |||
| dbb739b060 | |||
| bd3181fb9d | |||
| 915ec103fc | |||
| 4108019eab | |||
| 84a5d997b4 | |||
| ad89ad001d | |||
| 4fd6772f2e | |||
| fbeca7cfee | |||
| b582b6c88e | |||
| 00e066ca4e | |||
| 4214a4b4de | |||
| af392e1bc9 | |||
| 74f9696023 | |||
| 1f76cf38a7 | |||
| e5242b37a4 | |||
| 38206f34fe | |||
| e950abd805 | |||
| 4c0eae4b11 | |||
| e6b79ce2c2 | |||
| ba14526fc5 | |||
| 563d77ba65 | |||
| fb5ffc3195 | |||
| 1b6dfbb460 | |||
| 01f3b408e9 | |||
| ea9dc35323 | |||
| 1cf3dc4d6f | |||
| 0696e488e6 | |||
| 0d5aae3f1b | |||
| ee98bc6fcb | |||
| 9aa0179d2e | |||
| cba39f80dc | |||
| c8ac5710cf | |||
| b90eb6d426 | |||
| 07d1dd6fc3 | |||
| d5f337df80 | |||
| d4df1a5104 | |||
| 302f3cb3f5 | |||
| 180f14dfb0 | |||
| 32cffa34fa | |||
| ed0dacb435 | |||
| 91d410cbce | |||
| 75c4362d97 | |||
| cf4ef928b8 | |||
| 50735b74ea | |||
| 1a3c2e08ce | |||
| 39fdf16280 | |||
| ca6484aea5 | |||
| 77498967ec | |||
| 10d7500ef3 | |||
| e27877c477 | |||
| 03579bc625 | |||
| a1c7b9223f | |||
| 2fc03566d1 | |||
| 079d9b6282 | |||
| 5723ca07a8 | |||
| a452526cbb | |||
| 3ebec7aa4a | |||
| c88e5d9d82 | |||
| 10e1ca8396 | |||
| 0809f75673 | |||
| 35ca35c28a | |||
| 75ce36cf9c | |||
| a69b8f029b | |||
| fd6b490de1 | |||
| 843f9ceed0 | |||
| 543c553786 | |||
| a270f6c1e5 | |||
| 46767ca7e2 | |||
| a680f89a46 | |||
| 93ffdf3c86 | |||
| eaf185d89e | |||
| 4fbb12643c | |||
| bb99809b4f | |||
| 4f249fc777 | |||
| f87cc2f295 | |||
| 9de86df070 | |||
| 5e30d338af | |||
| e8fb8fa21c | |||
| e53d5944d0 | |||
| 22bbe3a1ed | |||
| 43b8fd6c9e | |||
| 8c8f09dab9 | |||
| 2eac74ea0c | |||
| d9515f7723 | |||
| 817689001c | |||
| cd062d6670 | |||
| 860693edcd | |||
| 2a4f78a762 | |||
| 8a2565adad | |||
| a1616b161d | |||
| 0040fae499 | |||
| acc8731e09 | |||
| 8cd9035f3c | |||
| 9370d5c935 | |||
| e6791f8cb8 | |||
| 51872a3277 | |||
| d5f9d1f583 | |||
| 3ac6e4d5b8 |
@@ -0,0 +1,17 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*
|
||||||
|
README.md
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
coverage
|
||||||
|
*.tsbuildinfo
|
||||||
|
dist
|
||||||
|
build
|
||||||
+46
-18
@@ -1,23 +1,51 @@
|
|||||||
# Since the ".env" file is gitignored, you can use the ".env.example" file to
|
# Copy this file to .env before running Docker Compose:
|
||||||
# build a new ".env" file when you clone the repo. Keep this file up-to-date
|
# cp .env.example .env
|
||||||
# when you add new variables to `.env`.
|
|
||||||
|
|
||||||
# This file will be committed to version control, so make sure not to have any
|
# Runtime
|
||||||
# secrets in it. If you are cloning this repo, create a copy of this file named
|
NODE_ENV=production
|
||||||
# ".env" and populate it with your secrets.
|
WEB_PORT=3000
|
||||||
|
|
||||||
# When adding additional environment variables, the schema in "/src/env.js"
|
# Auth
|
||||||
# should be updated accordingly.
|
# Generate with: openssl rand -base64 32
|
||||||
|
AUTH_SECRET=change-me-generate-a-real-secret
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
# Next Auth
|
# Public app URL
|
||||||
# You can generate a new secret on the command line with:
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
# npx auth secret
|
|
||||||
# https://next-auth.js.org/configuration/options#secret
|
|
||||||
AUTH_SECRET=""
|
|
||||||
|
|
||||||
# Next Auth Discord Provider
|
# Postgres used by docker-compose.yml
|
||||||
AUTH_DISCORD_ID=""
|
POSTGRES_USER=postgres
|
||||||
AUTH_DISCORD_SECRET=""
|
POSTGRES_PASSWORD=postgres
|
||||||
|
POSTGRES_DB=postgres
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
||||||
|
DB_DISABLE_SSL=true
|
||||||
|
|
||||||
# Drizzle
|
# White-label defaults used at image build time.
|
||||||
DATABASE_URL="file:./db.sqlite"
|
# Admin-managed platform branding in the app can override these after setup.
|
||||||
|
NEXT_PUBLIC_BRAND_NAME="beenvoice"
|
||||||
|
NEXT_PUBLIC_BRAND_TAGLINE="Simple and efficient invoicing for freelancers and small businesses"
|
||||||
|
NEXT_PUBLIC_BRAND_LOGO_TEXT="beenvoice"
|
||||||
|
NEXT_PUBLIC_BRAND_ICON="$"
|
||||||
|
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME="beenvoice"
|
||||||
|
NEXT_PUBLIC_DEFAULT_FONT="brand"
|
||||||
|
NEXT_PUBLIC_DEFAULT_BODY_FONT="brand"
|
||||||
|
NEXT_PUBLIC_DEFAULT_HEADING_FONT="brand"
|
||||||
|
NEXT_PUBLIC_DEFAULT_RADIUS="xl"
|
||||||
|
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE="floating"
|
||||||
|
|
||||||
|
# Email delivery via Resend (optional)
|
||||||
|
# Leave blank to disable invoice/password-reset email delivery.
|
||||||
|
RESEND_API_KEY=
|
||||||
|
RESEND_DOMAIN=
|
||||||
|
|
||||||
|
# Analytics via Umami (optional)
|
||||||
|
# Leave website ID blank to disable analytics.
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
|
||||||
|
|
||||||
|
# SSO via Authentik OIDC (optional)
|
||||||
|
NEXT_PUBLIC_AUTHENTIK_ENABLED=false
|
||||||
|
AUTHENTIK_ISSUER=
|
||||||
|
AUTHENTIK_CLIENT_ID=
|
||||||
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
|
AUTHENTIK_ORIGIN=
|
||||||
|
|||||||
+2
-3
@@ -34,10 +34,9 @@ yarn-error.log*
|
|||||||
# local env files
|
# local env files
|
||||||
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
||||||
.env
|
.env
|
||||||
|
.env.prod
|
||||||
.env*.local
|
.env*.local
|
||||||
|
.env*.production
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ beenvoice is a professional invoicing application built with the T3 stack (Next.
|
|||||||
- Protected routes require authentication
|
- Protected routes require authentication
|
||||||
- Follow NextAuth.js security best practices
|
- Follow NextAuth.js security best practices
|
||||||
|
|
||||||
|
### Development Tools
|
||||||
|
- Use ESLint and Prettier for code formatting
|
||||||
|
- Use TypeScript for type safety
|
||||||
|
- Exclusively use bun for development and production. Do not use Node.js or Deno.
|
||||||
|
- Stay away from starting development servers or running builds unless absolutely necessary.
|
||||||
|
- Run lints and typechecks when helpful.
|
||||||
|
|
||||||
## Component Architecture
|
## Component Architecture
|
||||||
|
|
||||||
### UI Components (shadcn/ui)
|
### UI Components (shadcn/ui)
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM oven/bun:1 AS base
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
FROM base AS install
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM base AS build
|
||||||
|
COPY --from=install /usr/src/app/node_modules node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
SKIP_ENV_VALIDATION=1 \
|
||||||
|
NODE_OPTIONS=--max-old-space-size=4096 \
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000 \
|
||||||
|
AUTH_SECRET=docker-build-placeholder-secret-do-not-use \
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
||||||
|
RUN bun run build && bun build src/server/db/migrate.ts --target=bun --outfile=migrate.js
|
||||||
|
|
||||||
|
FROM base AS release
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
PORT=3000 \
|
||||||
|
HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
COPY --from=build /usr/src/app/.next/standalone ./
|
||||||
|
COPY --from=build /usr/src/app/.next/static ./.next/static
|
||||||
|
COPY --from=build /usr/src/app/public ./public
|
||||||
|
COPY --from=build /usr/src/app/migrate.js ./migrate.js
|
||||||
|
COPY --from=build /usr/src/app/drizzle ./drizzle
|
||||||
|
|
||||||
|
RUN chmod -R a+rX drizzle migrate.js public
|
||||||
|
|
||||||
|
USER bun
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["sh", "-c", "bun migrate.js && bun server.js"]
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|

|
||||||
|
|
||||||
# beenvoice - Invoicing Made Simple
|
# beenvoice - Invoicing Made Simple
|
||||||
|
|
||||||
A modern, professional invoicing application built for freelancers and small businesses. beenvoice provides a clean, efficient way to manage clients and create professional invoices with ease.
|
A modern, professional invoicing application built for freelancers and small businesses. beenvoice provides a clean, efficient way to manage clients and create professional invoices with ease.
|
||||||
@@ -6,72 +8,99 @@ A modern, professional invoicing application built for freelancers and small bus
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **🔐 Secure Authentication** - Email/password registration and sign-in with NextAuth.js
|
- **🔐 Secure Authentication** - Email/password registration and sign-in with better-auth, plus SSO via Authentik OIDC
|
||||||
- **👥 Client Management** - Create, edit, and manage client information
|
- **👥 Client Management** - Create, edit, and manage client information
|
||||||
|
- **🏢 Business Profiles** - Manage your business details, logo, and email settings
|
||||||
- **📄 Professional Invoices** - Generate detailed invoices with line items
|
- **📄 Professional Invoices** - Generate detailed invoices with line items
|
||||||
|
- **📅 Timesheet View** - Calendar-based time entry with month and week views
|
||||||
|
- **📧 Email Delivery** - Send invoices via email using Resend
|
||||||
|
- **📥 PDF Export** - Download invoices as professional PDFs
|
||||||
|
- **📊 CSV Import** - Bulk import invoice data from CSV files
|
||||||
- **💰 Flexible Pricing** - Set custom rates and calculate totals automatically
|
- **💰 Flexible Pricing** - Set custom rates and calculate totals automatically
|
||||||
- **📱 Responsive Design** - Works seamlessly on desktop, tablet, and mobile
|
- **📱 Responsive Design** - Works seamlessly on desktop, tablet, and mobile
|
||||||
- **🎨 Modern UI** - Clean, professional interface built with shadcn/ui
|
- **🎨 Modern UI** - Clean, professional interface built with shadcn/ui
|
||||||
- **⚡ Type-Safe** - Full TypeScript support with tRPC for API calls
|
- **⚡ Type-Safe** - Full TypeScript support with tRPC for API calls
|
||||||
- **💾 Local Database** - SQLite database with Drizzle ORM
|
- **💾 PostgreSQL Database** - Robust relational database with Drizzle ORM
|
||||||
|
|
||||||
## 🚀 Tech Stack
|
## 🚀 Tech Stack
|
||||||
|
|
||||||
- **Frontend**: Next.js 15 with App Router
|
- **Frontend**: Next.js 16 with App Router
|
||||||
- **Backend**: tRPC for type-safe API calls
|
- **Backend**: tRPC for type-safe API calls
|
||||||
- **Database**: Drizzle ORM with LibSQL (SQLite)
|
- **Database**: Drizzle ORM with PostgreSQL
|
||||||
- **Authentication**: NextAuth.js with email/password
|
- **Authentication**: better-auth with email/password and Authentik OIDC SSO
|
||||||
- **UI Components**: shadcn/ui with Tailwind CSS
|
- **UI Components**: shadcn/ui with Tailwind CSS v4
|
||||||
- **Styling**: Geist font family
|
- **Email**: Resend for transactional email delivery
|
||||||
- **Package Manager**: Bun (with npm fallback)
|
- **PDF**: @react-pdf/renderer for invoice PDF generation
|
||||||
|
- **Package Manager**: Bun
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 18+ or Bun
|
- Node.js 18+ or Bun
|
||||||
|
- Docker & Docker Compose (for local PostgreSQL)
|
||||||
- Git
|
- Git
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
1. **Clone the repository**
|
1. **Clone the repository**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/yourusername/beenvoice.git
|
git clone https://github.com/yourusername/beenvoice.git
|
||||||
cd beenvoice
|
cd beenvoice
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Install dependencies**
|
2. **Install dependencies**
|
||||||
```bash
|
|
||||||
# Using Bun (recommended)
|
|
||||||
bun install
|
|
||||||
|
|
||||||
# Or using npm
|
```bash
|
||||||
npm install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Set up environment variables**
|
3. **Set up environment variables**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `.env.local` and add your configuration:
|
Edit `.env.local` and add your configuration:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL="file:./db.sqlite"
|
# Database
|
||||||
NEXTAUTH_SECRET="your-secret-key-here"
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice"
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
DB_DISABLE_SSL="true"
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
AUTH_SECRET="your-secret-key-here"
|
||||||
|
BETTER_AUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# Application
|
||||||
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
NODE_ENV="development"
|
||||||
|
|
||||||
|
# Email (optional for local dev)
|
||||||
|
RESEND_API_KEY="your-resend-api-key"
|
||||||
|
RESEND_DOMAIN="yourdomain.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Initialize the database**
|
4. **Start the development database**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up -d db
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Push the database schema**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run db:push
|
bun run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Start the development server**
|
6. **Start the development server**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **Open your browser**
|
7. **Open your browser**
|
||||||
Navigate to [http://localhost:3000](http://localhost:3000)
|
Navigate to [http://localhost:3000](http://localhost:3000)
|
||||||
|
|
||||||
## 🏗️ Project Structure
|
## 🏗️ Project Structure
|
||||||
@@ -80,21 +109,29 @@ A modern, professional invoicing application built for freelancers and small bus
|
|||||||
beenvoice/
|
beenvoice/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/ # Next.js App Router pages
|
│ ├── app/ # Next.js App Router pages
|
||||||
│ │ ├── api/ # API routes (NextAuth, tRPC)
|
│ │ ├── api/ # API routes (better-auth, tRPC)
|
||||||
│ │ ├── auth/ # Authentication pages
|
│ │ ├── auth/ # Authentication pages
|
||||||
│ │ ├── clients/ # Client management pages
|
│ │ ├── dashboard/ # Main app pages
|
||||||
│ │ ├── invoices/ # Invoice management pages
|
│ │ │ ├── clients/ # Client management pages
|
||||||
|
│ │ │ ├── invoices/ # Invoice management pages
|
||||||
|
│ │ │ └── businesses/ # Business profile pages
|
||||||
│ │ └── _components/ # Page-specific components
|
│ │ └── _components/ # Page-specific components
|
||||||
│ ├── components/ # Shared UI components
|
│ ├── components/ # Shared UI components
|
||||||
|
│ │ ├── ui/ # shadcn/ui components
|
||||||
|
│ │ ├── data/ # Data display components
|
||||||
|
│ │ ├── forms/ # Form components
|
||||||
|
│ │ └── layout/ # Layout components
|
||||||
│ ├── server/ # Server-side code
|
│ ├── server/ # Server-side code
|
||||||
│ │ ├── api/ # tRPC routers
|
│ │ ├── api/ # tRPC routers
|
||||||
│ │ ├── auth/ # NextAuth configuration
|
|
||||||
│ │ └── db/ # Database schema and connection
|
│ │ └── db/ # Database schema and connection
|
||||||
|
│ ├── lib/ # Utilities (auth, pdf export, etc.)
|
||||||
│ ├── styles/ # Global styles
|
│ ├── styles/ # Global styles
|
||||||
│ └── trpc/ # tRPC client configuration
|
│ └── trpc/ # tRPC client configuration
|
||||||
├── drizzle/ # Database migrations
|
├── drizzle/ # Database migrations
|
||||||
├── public/ # Static assets
|
├── public/ # Static assets
|
||||||
└── docs/ # Documentation
|
├── docs/ # Documentation
|
||||||
|
├── docker-compose.yml # Deployment compose stack
|
||||||
|
└── docker-compose.dev.yml # Development overrides with exposed PostgreSQL
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎯 Usage
|
## 🎯 Usage
|
||||||
@@ -104,41 +141,57 @@ beenvoice/
|
|||||||
1. **Register an Account**
|
1. **Register an Account**
|
||||||
- Visit the sign-up page
|
- Visit the sign-up page
|
||||||
- Enter your name, email, and password
|
- Enter your name, email, and password
|
||||||
- Verify your email (if configured)
|
|
||||||
|
|
||||||
2. **Add Your First Client**
|
2. **Set Up Your Business**
|
||||||
|
- Navigate to Business Settings
|
||||||
|
- Add your business name, contact info, and logo
|
||||||
|
- Configure email settings for invoice delivery (Resend API key + domain)
|
||||||
|
|
||||||
|
3. **Add Your First Client**
|
||||||
- Navigate to the Clients page
|
- Navigate to the Clients page
|
||||||
- Click "Add New Client"
|
- Click "Add New Client"
|
||||||
- Fill in client details (name, email, phone, address)
|
- Fill in client details (name, email, phone, address)
|
||||||
|
|
||||||
3. **Create an Invoice**
|
4. **Create an Invoice**
|
||||||
- Go to the Invoices page
|
- Go to the Invoices page
|
||||||
- Click "Create New Invoice"
|
- Click "Create New Invoice"
|
||||||
- Select a client
|
- Select a client and optionally a business profile
|
||||||
- Add line items with descriptions, dates, hours, and rates
|
- Add line items with descriptions, dates, hours, and rates
|
||||||
- Save and generate your invoice
|
- Use the Timesheet tab for calendar-based time entry
|
||||||
|
- Save and send or download as PDF
|
||||||
|
|
||||||
### Features Overview
|
### Features Overview
|
||||||
|
|
||||||
#### Client Management
|
#### Client Management
|
||||||
|
|
||||||
- Create and edit client profiles
|
- Create and edit client profiles
|
||||||
- Store contact information and addresses
|
- Store contact information and addresses
|
||||||
|
- Set default hourly rates per client
|
||||||
- Search and filter client list
|
- Search and filter client list
|
||||||
- View client history
|
|
||||||
|
|
||||||
#### Invoice Creation
|
#### Invoice Creation
|
||||||
- Select from existing clients
|
|
||||||
- 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
|
- Set custom rates per item
|
||||||
- Automatic total calculations
|
- Automatic total calculations with configurable tax rate
|
||||||
|
- Timesheet calendar view for date-based time tracking
|
||||||
- Professional invoice formatting
|
- Professional invoice formatting
|
||||||
|
|
||||||
|
#### Invoice Delivery
|
||||||
|
|
||||||
|
- Send invoices via email directly from the app
|
||||||
|
- Rich text email composer with preview
|
||||||
|
- Resend and re-deliver sent invoices
|
||||||
|
- Track invoice status: Draft → Sent → Paid (+ Overdue)
|
||||||
|
|
||||||
#### User Interface
|
#### User Interface
|
||||||
|
|
||||||
- Clean, modern design
|
- Clean, modern design
|
||||||
- Responsive layout
|
- Fully responsive — desktop, tablet, and mobile
|
||||||
- Intuitive navigation
|
- Intuitive navigation with breadcrumbs
|
||||||
- Toast notifications for feedback
|
- Toast notifications for feedback
|
||||||
- Modal dialogs for forms
|
- Dark mode support
|
||||||
|
|
||||||
## 🔧 Development
|
## 🔧 Development
|
||||||
|
|
||||||
@@ -146,44 +199,72 @@ beenvoice/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
# Development
|
||||||
bun run dev # Start development server
|
bun run dev # Start development server (Turbo)
|
||||||
bun run build # Build for production
|
bun run build # Build for production
|
||||||
bun run start # Start production server
|
bun run start # Start production server
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
bun run db:push # Push schema changes to database
|
bun run db:push # Push schema changes to database
|
||||||
|
bun run db:migrate # Run migrations
|
||||||
bun run db:studio # Open Drizzle Studio
|
bun run db:studio # Open Drizzle Studio
|
||||||
bun run db:generate # Generate new migration
|
bun run db:generate # Generate new migration
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
bun run docker:up # Start deployment compose stack
|
||||||
|
bun run docker:dev:up # Start development compose stack with exposed PostgreSQL
|
||||||
|
bun run docker:down # Stop Docker services
|
||||||
|
|
||||||
# Code Quality
|
# Code Quality
|
||||||
bun run lint # Run ESLint
|
bun run lint # Run ESLint
|
||||||
bun run format # Format code with Prettier
|
bun run lint:fix # Fix ESLint issues
|
||||||
bun run type-check # Run TypeScript type checking
|
bun run format:write # Format code with Prettier
|
||||||
|
bun run typecheck # Run TypeScript type checking
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
Use the base compose file for deployment. It keeps PostgreSQL internal to the
|
||||||
|
compose network:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
For local development, use the dev compose file to expose PostgreSQL on
|
||||||
|
`${POSTGRES_PORT:-5432}`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `DISABLE_SIGNUPS=true` to block new email/password account registration.
|
||||||
|
|
||||||
### Database Schema
|
### Database Schema
|
||||||
|
|
||||||
The application uses four main tables:
|
The application uses the following core tables:
|
||||||
|
|
||||||
- **users**: User accounts and authentication
|
- **users** - User accounts and authentication
|
||||||
- **clients**: Client information and contact details
|
- **sessions** - Active user sessions
|
||||||
- **invoices**: Invoice headers with client relationships
|
- **clients** - Client information and contact details
|
||||||
- **invoice_items**: Individual line items with pricing
|
- **businesses** - Business profiles with email/logo settings
|
||||||
|
- **invoices** - Invoice headers with client and business relationships
|
||||||
|
- **invoice_items** - Individual line items with pricing and position ordering
|
||||||
|
|
||||||
### API Development
|
### API Development
|
||||||
|
|
||||||
All API endpoints are built with tRPC for type safety:
|
All API endpoints are built with tRPC for type safety:
|
||||||
|
|
||||||
- **Authentication**: NextAuth.js integration
|
- **Authentication**: better-auth integration (email/password + OIDC)
|
||||||
- **Clients**: CRUD operations for client management
|
- **Clients**: CRUD operations for client management
|
||||||
- **Invoices**: Invoice creation and management
|
- **Businesses**: Business profile management
|
||||||
|
- **Invoices**: Invoice creation, management, and status tracking
|
||||||
- **Validation**: Zod schemas for input validation
|
- **Validation**: Zod schemas for input validation
|
||||||
|
|
||||||
## 🎨 Customization
|
## 🎨 Customization
|
||||||
|
|
||||||
### Styling
|
### Styling
|
||||||
|
|
||||||
The app uses Tailwind CSS with a custom design system:
|
The app uses Tailwind CSS v4 with a custom design system:
|
||||||
|
|
||||||
- **Primary Color**: Green (#16a34a)
|
- **Primary Color**: Green (#16a34a)
|
||||||
- **Font**: Geist for professional typography
|
- **Font**: Geist for professional typography
|
||||||
@@ -193,37 +274,63 @@ The app uses Tailwind CSS with a custom design system:
|
|||||||
### Branding
|
### Branding
|
||||||
|
|
||||||
Update the logo and colors in:
|
Update the logo and colors in:
|
||||||
|
|
||||||
- `src/components/logo.tsx` - Main logo component
|
- `src/components/logo.tsx` - Main logo component
|
||||||
- `src/styles/globals.css` - Color variables
|
- `src/styles/globals.css` - Color variables
|
||||||
- `src/app/layout.tsx` - Font configuration
|
- `src/app/layout.tsx` - Font configuration
|
||||||
|
|
||||||
## 🚀 Deployment
|
## 🚀 Deployment
|
||||||
|
|
||||||
### Vercel (Recommended)
|
You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.).
|
||||||
|
|
||||||
1. Push your code to GitHub
|
1. **Build the application:**
|
||||||
2. Connect your repository to Vercel
|
|
||||||
3. Set environment variables in Vercel dashboard
|
|
||||||
4. Deploy automatically on push
|
|
||||||
|
|
||||||
### Other Platforms
|
```bash
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
The app can be deployed to any platform that supports Next.js:
|
2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production)
|
||||||
|
|
||||||
- **Netlify**: Use the Next.js build command
|
3. **Run database migrations:**
|
||||||
- **Railway**: Connect your GitHub repository
|
|
||||||
- **DigitalOcean App Platform**: Deploy with automatic scaling
|
```bash
|
||||||
|
bun run db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Start the server:**
|
||||||
|
```bash
|
||||||
|
bun start
|
||||||
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
Required for production:
|
Required for production:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL="your-database-url"
|
DATABASE_URL="postgresql://user:password@host:5432/dbname"
|
||||||
NEXTAUTH_SECRET="your-secret-key"
|
AUTH_SECRET="your-long-random-secret"
|
||||||
NEXTAUTH_URL="https://your-domain.com"
|
BETTER_AUTH_URL="https://your-domain.com"
|
||||||
|
NEXT_PUBLIC_APP_URL="https://your-domain.com"
|
||||||
|
NODE_ENV="production"
|
||||||
|
|
||||||
|
# Email (required for invoice sending)
|
||||||
|
RESEND_API_KEY="re_xxxxxxxxxxxx"
|
||||||
|
RESEND_DOMAIN="yourdomain.com"
|
||||||
|
|
||||||
|
# Optional: Authentik SSO
|
||||||
|
AUTHENTIK_ISSUER="https://your-authentik-instance/application/o/beenvoice/"
|
||||||
|
AUTHENTIK_CLIENT_ID="your-client-id"
|
||||||
|
AUTHENTIK_CLIENT_SECRET="your-client-secret"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Other Platforms
|
||||||
|
|
||||||
|
The app can be deployed to any platform that supports Next.js:
|
||||||
|
|
||||||
|
- **Coolify**: Deploy with Docker Compose support
|
||||||
|
- **Railway**: Connect your GitHub repository (includes managed PostgreSQL)
|
||||||
|
- **DigitalOcean App Platform**: Deploy with automatic scaling
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
@@ -237,8 +344,7 @@ NEXTAUTH_URL="https://your-domain.com"
|
|||||||
- Follow TypeScript best practices
|
- Follow TypeScript best practices
|
||||||
- Use shadcn/ui components for consistency
|
- Use shadcn/ui components for consistency
|
||||||
- Implement proper error handling
|
- Implement proper error handling
|
||||||
- Add tests for new features
|
- Follow the existing code style (Prettier + ESLint configs provided)
|
||||||
- Follow the existing code style
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
@@ -248,14 +354,14 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|||||||
|
|
||||||
- [T3 Stack](https://create.t3.gg/) for the excellent development stack
|
- [T3 Stack](https://create.t3.gg/) for the excellent development stack
|
||||||
- [shadcn/ui](https://ui.shadcn.com/) for beautiful UI components
|
- [shadcn/ui](https://ui.shadcn.com/) for beautiful UI components
|
||||||
- [NextAuth.js](https://next-auth.js.org/) for authentication
|
- [better-auth](https://www.better-auth.com/) for modern authentication
|
||||||
- [Drizzle ORM](https://orm.drizzle.team/) for database management
|
- [Drizzle ORM](https://orm.drizzle.team/) for database management
|
||||||
|
- [Resend](https://resend.com/) for reliable email delivery
|
||||||
|
|
||||||
## 📞 Support
|
## 📞 Support
|
||||||
|
|
||||||
- **Issues**: [GitHub Issues](https://github.com/yourusername/beenvoice/issues)
|
- **Issues**: [GitHub Issues](https://github.com/yourusername/beenvoice/issues)
|
||||||
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/beenvoice/discussions)
|
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/beenvoice/discussions)
|
||||||
- **Email**: support@beenvoice.com
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,510 +0,0 @@
|
|||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
BEGIN TRANSACTION;
|
|
||||||
CREATE TABLE `beenvoice_account` (
|
|
||||||
`userId` text(255) NOT NULL,
|
|
||||||
`type` text(255) NOT NULL,
|
|
||||||
`provider` text(255) NOT NULL,
|
|
||||||
`providerAccountId` text(255) NOT NULL,
|
|
||||||
`refresh_token` text,
|
|
||||||
`access_token` text,
|
|
||||||
`expires_at` integer,
|
|
||||||
`token_type` text(255),
|
|
||||||
`scope` text(255),
|
|
||||||
`id_token` text,
|
|
||||||
`session_state` text(255),
|
|
||||||
PRIMARY KEY(`provider`, `providerAccountId`),
|
|
||||||
FOREIGN KEY (`userId`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
CREATE TABLE `beenvoice_invoice_item` (
|
|
||||||
`id` text(255) PRIMARY KEY NOT NULL,
|
|
||||||
`invoiceId` text(255) NOT NULL,
|
|
||||||
`date` integer NOT NULL,
|
|
||||||
`description` text(500) NOT NULL,
|
|
||||||
`hours` real NOT NULL,
|
|
||||||
`rate` real NOT NULL,
|
|
||||||
`amount` real NOT NULL,
|
|
||||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL, `position` integer DEFAULT 0 NOT NULL,
|
|
||||||
FOREIGN KEY (`invoiceId`) REFERENCES `beenvoice_invoice`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('9b237b0e-d47e-47d3-9351-777d10c84d38','76d570fe-bfec-47bd-a7fa-b4ee8133c78e',64731153600,'Virtual',1.5,20.0,30.0,1752132158,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('8fb85a95-50f9-4375-86d2-5e0e334d87ce','76d570fe-bfec-47bd-a7fa-b4ee8133c78e',64731326400,'In person',3.0,20.0,60.0,1752132158,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d9f841ae-4c70-4b3d-ba6a-befec3e07693','76d570fe-bfec-47bd-a7fa-b4ee8133c78e',64732363200,'In person',2.0,20.0,40.0,1752132158,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('fd91ea66-4c98-468d-a1ae-1d6715c028c2','76d570fe-bfec-47bd-a7fa-b4ee8133c78e',64732536000,'In person',4.5,20.0,90.0,1752132158,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('bb1b3ccc-35be-47b9-a328-386d7fdc0260','61c3d28c-5031-4372-86e3-5bf895411046',64733054400,'In person',2.5,20.0,50.0,1752132159,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('33de41fb-3117-4bef-8ced-b9955538f920','61c3d28c-5031-4372-86e3-5bf895411046',64733140800,'In person',5.5,20.0,110.0,1752132159,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('22f8db2c-4d80-4847-8927-7fcce399627e','61c3d28c-5031-4372-86e3-5bf895411046',64733572800,'In person',3.0,20.0,60.0,1752132159,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('52d4126f-3e1b-4f11-a1cd-c14f64ef8785','61c3d28c-5031-4372-86e3-5bf895411046',64733745600,'Race day (flat rate)',1.0,100.0,100.0,1752132159,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('b9588fcb-2081-44f4-a167-2b51567d89a1','57fcd73a-0876-4e91-9856-0f9c9695fcd1',1621051200,'Race Day (fixed)',1.0,100.0,100.0,1752132159,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('fd024a5f-1bf3-4a08-9fb1-fd39502158eb','57fcd73a-0876-4e91-9856-0f9c9695fcd1',1621656000,'Race Day (fixed)',1.0,100.0,100.0,1752132159,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('63e0e171-d1f9-43a7-a465-d883b4996b53','57fcd73a-0876-4e91-9856-0f9c9695fcd1',1622865600,'Race Day (fixed)',1.0,100.0,100.0,1752132159,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('af8b2c9d-147b-49b4-b0a7-0a98ba63abee','555b1f8c-7a57-427f-bea8-ee4a3f2a2bf2',1620619200,'Fix routers',3.0,20.0,60.0,1752132159,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f9f4712d-9096-4322-978f-3fdff9591939','555b1f8c-7a57-427f-bea8-ee4a3f2a2bf2',1620792000,'Race Day (fixed)',1.0,100.0,100.0,1752132159,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('dc0dc83c-093a-42c1-9c8e-b658f5cac7ef','555b1f8c-7a57-427f-bea8-ee4a3f2a2bf2',1621396800,'Race Day (Fixed)',1.0,100.0,100.0,1752132159,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c1d379b1-70ea-44c4-a3cd-d4e1f1510722','555b1f8c-7a57-427f-bea8-ee4a3f2a2bf2',1622520000,'RDP Login Configuration',2.5,20.0,50.0,1752132159,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('1212495d-3d81-47ed-ad57-2f938330a95b','555b1f8c-7a57-427f-bea8-ee4a3f2a2bf2',1338696000,'Virtual Database Install/Setup',5.0,20.0,100.0,1752132159,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('dfd60b61-908c-4a8e-b768-c471cbf1699a','555b1f8c-7a57-427f-bea8-ee4a3f2a2bf2',1623297600,'Race Day (fixed)',1.0,100.0,100.0,1752132159,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c79fc31f-2abb-4b4e-968b-8ced90992bfb','4fb5d8be-2588-4187-955d-e7643b08619f',1627617600,'Office Internet/3Play Configuration',4.0,20.0,80.0,1752132160,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('286c3631-0a36-4177-83e2-e041d3e5e198','4fb5d8be-2588-4187-955d-e7643b08619f',1627704000,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('a0599f94-dcbb-4ff7-8f69-f685b200d702','4fb5d8be-2588-4187-955d-e7643b08619f',1628308800,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('96f1bee1-1117-4fb8-a40a-4fcd485d6528','f48104da-1baa-4a70-9d0c-c03f4017f60d',1628740800,'Stream Deck/Tower Server',2.5,20.0,50.0,1752132160,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('41fcea00-259c-433c-8744-1da4297ee261','f48104da-1baa-4a70-9d0c-c03f4017f60d',1628913600,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('a5a677ea-1c26-4c93-bee5-4e7193d8fc54','f48104da-1baa-4a70-9d0c-c03f4017f60d',1629432000,'Office Server Ransomware/Data Recovery',5.0,20.0,100.0,1752132160,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('fc9e7932-aae0-4611-8dfd-439632e02efe','f48104da-1baa-4a70-9d0c-c03f4017f60d',1629518400,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('14d8f2e4-79a1-4f52-80cb-495422c2ff6c','f48104da-1baa-4a70-9d0c-c03f4017f60d',1629864000,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('848765c1-2f93-4fe0-bd54-83a8ed6e028b','d0c7c941-d0e4-4d55-b1e5-10ed71f1c9f5',1630728000,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('28b59943-9beb-4c64-bf94-f10729ef55e9','d0c7c941-d0e4-4d55-b1e5-10ed71f1c9f5',1631332800,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('ef1b5cc8-046e-4720-9126-365bf2011cef','d0c7c941-d0e4-4d55-b1e5-10ed71f1c9f5',1631419200,'Office Server Data Migration (Online)',2.0,20.0,40.0,1752132160,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('07d42569-5d78-4ddc-9146-07c68df081f0','d0c7c941-d0e4-4d55-b1e5-10ed71f1c9f5',1631937600,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f87f4371-4d88-461b-9e20-218841842abd','6c4314c7-7bc7-4d8a-9513-59a1ebcfd890',1635739200,'IT Move server/Vmix/Backups',2.0,20.0,40.0,1752132161,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('fb6b4cf4-b569-42ac-ba14-53e242d07560','6c4314c7-7bc7-4d8a-9513-59a1ebcfd890',1635825600,'Prep In Car Cameras',3.0,20.0,60.0,1752132161,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f31d6dab-9af3-476c-a272-6e53c3e81a51','6c4314c7-7bc7-4d8a-9513-59a1ebcfd890',1636520400,'Race Day,Islip 300',1.0,100.0,100.0,1752132161,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('0a800d16-bf03-4139-93f6-872a455fbd57','b018eaca-b4b1-4c96-8e40-2a1ab5211e48',1649390400,'Hoosier Tire Scanning',3.0,20.0,60.0,1752132161,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('91c2086d-590a-45ff-8857-006964144c6c','b018eaca-b4b1-4c96-8e40-2a1ab5211e48',1649736000,'SSD Migration/Data Backup',4.0,20.0,80.0,1752132161,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d705c999-1112-4215-97c8-81888281a27d','b018eaca-b4b1-4c96-8e40-2a1ab5211e48',1650513600,'Roster Numbers/Data Migration',5.5,20.0,110.0,1752132161,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('1cf32daa-b16e-47a9-8d17-3bb65e8bf654','a0da2a05-5681-46fd-b988-235ec24971e2',1651636800,'Laptop setup/Facebook stream',5.0,20.0,100.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('87cee56a-7582-4015-9183-7b917b685b7a','a0da2a05-5681-46fd-b988-235ec24971e2',1652500800,'Race Day',1.0,100.0,100.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('adf5caac-3381-4811-aa9a-fe64c6c0ad20','713a368a-f7de-4de8-95dd-2a4a2d626fa1',1652500800,'Tire Sales (Hoosier)',3.0,20.0,60.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d278998a-ed4e-47bd-8915-35124d8bc27f','713a368a-f7de-4de8-95dd-2a4a2d626fa1',1652846400,'Raceway CMS Development',6.0,20.0,120.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('694aaa24-b883-4aa1-b365-3e3ded6e9c4f','713a368a-f7de-4de8-95dd-2a4a2d626fa1',1652932800,'Raceway CMS Development',5.0,20.0,100.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('9f5571af-e79e-4254-a370-deb25f16f06c','713a368a-f7de-4de8-95dd-2a4a2d626fa1',1653019200,'Raceway CMS Development',4.0,20.0,80.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d9f1fcca-a6f1-4f4e-a6ba-52ea102db90a','713a368a-f7de-4de8-95dd-2a4a2d626fa1',1653105600,'Race day (RR)',6.0,20.0,120.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('ebb93ccc-4a9d-4d6f-8584-f044377fdc00','713a368a-f7de-4de8-95dd-2a4a2d626fa1',1653105600,'Tire Sales (Hoosier)',3.0,20.0,60.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('a4f27be7-68ec-492a-b127-21fa207bde52','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1653192000,'Raceway CMS Development',3.0,20.0,60.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('9749ad17-0e9b-4682-8011-aee73425354b','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1653278400,'Raceway CMS Development',3.0,20.0,60.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('29fadf5f-a919-420c-a4a7-778d62b770f9','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1653364800,'Raceway CMS Development',4.0,20.0,80.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('b7f57cc5-ecea-49e3-bb42-15e90dfba1df','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1653537600,'Raceway CMS Development',1.0,20.0,20.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('a8380c9d-0444-4afe-b820-9597a871a903','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1653624000,'Generate points tables on site/tire LAN',4.0,20.0,80.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('fd72e334-4e6a-4462-82a5-cc5a8d3ecda0','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1654056000,'Press Release Publish',1.0,20.0,20.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('7fc11bdd-b740-4c2a-9cf1-2c3bab092f77','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1654315200,'Race Day (RR)',3.0,20.0,60.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('23fbcc77-d0d2-4d0e-90d5-e2f9cab790f7','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1654488000,'PR Archive Integration/Points Update',2.0,20.0,40.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('70be501b-a496-4f40-aebc-a4521fbcf4ba','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1654574400,'Press Release Website Deployment',2.0,20.0,40.0,1752132162,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('9bf35628-6046-44e2-a24f-681ea5bf7bb9','8704d2fe-8972-4dae-8062-2f5b81e14493',1654747200,'Raceway CMS Development',3.0,20.0,60.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('abea397c-2ea5-4788-9560-42ea0d508bce','8704d2fe-8972-4dae-8062-2f5b81e14493',1654833600,'Raceway CMS Development',3.0,20.0,60.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('99590ee9-7e6a-40ec-8925-b135457ba01e','8704d2fe-8972-4dae-8062-2f5b81e14493',1655092800,'TRMM Installation/Script Writing',3.0,20.0,60.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('e1f534be-9fe8-42f1-8ef4-bc4073d8ce2b','8704d2fe-8972-4dae-8062-2f5b81e14493',1655265600,'PC Updates',2.0,20.0,40.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('7e619cdf-af99-4c58-be0e-227324710e4e','8704d2fe-8972-4dae-8062-2f5b81e14493',1655352000,'3Play Remote Access Setup',2.0,20.0,40.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('7fd71054-7626-4f53-94e9-5fc4006ca3c4','8704d2fe-8972-4dae-8062-2f5b81e14493',1655524800,'Race Day',8.0,20.0,160.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c9d74e45-d270-45b4-9332-25db44c9d6d1','8704d2fe-8972-4dae-8062-2f5b81e14493',1655697600,'Move and reassign printer',3.0,20.0,60.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('ae2ef12b-43b2-454e-90bf-d8b150f89278','8704d2fe-8972-4dae-8062-2f5b81e14493',1655870400,'Website updates/PR Logic',2.0,20.0,40.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f96d139f-7f40-4a25-89cc-05510c782a7d','8704d2fe-8972-4dae-8062-2f5b81e14493',1656302400,'Website updates',2.0,20.0,40.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('e4c9c5ac-ee0d-490f-9f84-c542ad4b7c5c','8704d2fe-8972-4dae-8062-2f5b81e14493',1656475200,'Website updates/schedule/press-release',2.0,20.0,40.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('fb35940e-4199-4de8-a163-5ffac86ab0c4','babfc847-b37d-44f2-91a9-4251691c11b4',1658376000,'Server updates and TMM',5.0,20.0,100.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('8f4a5ca2-88d9-403e-9098-6b398d4be218','babfc847-b37d-44f2-91a9-4251691c11b4',1658548800,'Race Day (RR)',9.0,20.0,180.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('9107c846-b7b9-4d37-aecf-8b7cbc6cfc70','babfc847-b37d-44f2-91a9-4251691c11b4',1658721600,'CMS Development (remote)',4.0,20.0,80.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('51e881b1-0e7f-4bcd-87da-3512e2345337','babfc847-b37d-44f2-91a9-4251691c11b4',1658808000,'CMS Development (remote)',4.0,20.0,80.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('707b9108-81ea-4af6-aa2f-0de09220a1a8','babfc847-b37d-44f2-91a9-4251691c11b4',1658894400,'CMS Development (remote)',4.0,20.0,80.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('9d4c904c-2421-442f-9b45-09a330de83a4','babfc847-b37d-44f2-91a9-4251691c11b4',1658980800,'CMS Development (in person)',5.0,20.0,100.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('5685ea85-1190-45b5-bc0b-65d3a0ae37f5','babfc847-b37d-44f2-91a9-4251691c11b4',1659153600,'Race Day (Hoosier)',4.0,20.0,80.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('1d14d0de-642f-4266-a466-30ba7773b55f','babfc847-b37d-44f2-91a9-4251691c11b4',1659153600,'Race Day (RR) / Drone photography',6.0,20.0,120.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('93dfc1f6-e3d7-4c5a-8684-32534458bae9','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1660017600,'Update points, change prices, fix pdf display',1.0,20.0,20.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('24f10b26-5ccb-4217-ae89-11d601b16f67','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1660104000,'Add Penalty Reports to CMS',3.0,20.0,60.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f0f74076-daed-4e92-9693-ede280cc3e19','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1672203600,'Server drive replacement/data recovery',4.0,20.0,80.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('40e280f9-1a60-4765-9d3c-bbd6f7546e0a','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1643605200,'Add PR support to CMS',4.5,20.0,90.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c3160122-ac8c-4e1e-9f12-be70dae50d38','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1643691600,'Deploy PR update to CMS backend',3.0,20.0,60.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('1c23838c-134e-486a-8e94-7d2d085ce4b2','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1643778000,'Update database schema to support PR',2.0,20.0,40.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c6c1d55b-0895-4e4c-9b48-2de18dd4b3a8','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1643778000,'Patch riverheadraceway.com frontend for PR',2.5,20.0,50.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('1ce0b765-4330-454e-b339-679d3a61560c','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1644123600,'Begin new schema for schedule upload',2.0,20.0,40.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('41f3b4a2-d0ac-4813-8c97-353151735140','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1644642000,'Prototype rules upload page',3.0,20.0,60.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('296f2767-f2a1-48ee-afdd-7d9e5a5d4373','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1644814800,'Rules CMS page backend dev',1.0,20.0,20.0,1752132163,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('6df444f9-9013-4ba4-889f-288687bf40cd','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679025600,'Website fixes, Orbits Suite Update (5.9)',4.0,20.0,80.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('b8b7d422-8eb4-4550-8b8c-75d1eebb606c','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679284800,'Backblaze B2 Backup setup for VMs/web',7.0,20.0,140.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('0a871404-dd23-47ae-9b53-4db1762424db','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679371200,'Install and provision Active Directory SRV',4.5,20.0,90.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('e6ae1b2c-e842-431d-b2d1-fbe46f0d29d5','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679457600,'Update BackBlaze configurations',2.0,20.0,40.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f540b78f-10a7-4f10-9409-10f54eff831e','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679457600,'Website edits',1.0,20.0,20.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('7dd05c87-6a3e-40e6-836a-63dd7e22d52c','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679544000,'Remove policies from website',0.5,20.0,10.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('5dd99069-2388-4daa-b304-a5e6f000bbaa','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679544000,'Add dynamic roster to website',3.5,20.0,70.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('64f37c00-5f5f-49d3-85bc-786d083abc01','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679630400,'Update handicapping rules, modify reserved',1.5,20.0,30.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f7845e1a-cfaf-4b97-9404-985c578cd35d','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679889600,'Update CMOD rules, separate bandos',1.0,20.0,20.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('1967e5b2-98ae-493b-ba34-b28c81ebeed9','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1681272000,'Separate/ configure user accounts for FM',2.0,20.0,40.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('623d2cee-7c09-4626-bff7-16b4af75a3ac','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1681704000,'Generate and email RDP deployments',1.0,20.0,20.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('9a5cac21-e2f8-4028-b90d-2f1d1701abb6','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1681876800,'Generate roster CSV and convert to FM',2.0,20.0,40.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('dda3b050-8ad8-45fb-bee5-48bc2e94c469','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1682395200,'Troubleshoot FM access',1.5,20.0,30.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('2e562992-b42e-4d1d-99eb-ff354b2194d8','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1682568000,'Generate login certificates/install FM server',2.0,20.0,40.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('8542b97d-a5e5-4a95-b143-9677c9ca2c09','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1683000000,'Reset RDP cache on Vmix PC/initialize',1.5,20.0,30.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('54ba18de-ae79-4f36-a5f7-5e112e7033fe','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1683086400,'Unify user accounts on AD for FM',2.5,20.0,50.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('6446d0af-4267-42fb-b929-18705adf748a','0b057a65-fe7d-4495-8756-4dd61f6895e1',1683777600,'On-site- printer and system update/config',7.0,20.0,140.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('967b4092-caf5-4fe9-94ae-9ad05d021abd','0b057a65-fe7d-4495-8756-4dd61f6895e1',1683950400,'Race day',7.0,20.0,140.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d13204e9-14e8-4cf6-af8d-0d554f865897','0b057a65-fe7d-4495-8756-4dd61f6895e1',1684123200,'FM Maintenance/Web development',5.0,20.0,100.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('e3e9ce1b-ed84-47e3-822f-f844b7aa0484','0b057a65-fe7d-4495-8756-4dd61f6895e1',1684209600,'PointsSplitter Script (Remote)',1.5,20.0,30.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('eb535a1b-315f-4457-b742-72d01419b2cd','0b057a65-fe7d-4495-8756-4dd61f6895e1',1684296000,'vMix,New ticker and sponsors',5.0,20.0,100.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('8c318cea-7d7d-4ec5-a6df-63b46e1d36be','0b057a65-fe7d-4495-8756-4dd61f6895e1',1684382400,'Web Development (Remote)',4.0,20.0,80.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('6def29d4-4511-4705-b963-29717f881a7a','0b057a65-fe7d-4495-8756-4dd61f6895e1',1684468800,'MyRacePass/Website',5.0,20.0,100.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('2a3a5028-d561-43ac-af77-2a2af562b145','0b057a65-fe7d-4495-8756-4dd61f6895e1',1684641600,'Race day',5.0,20.0,100.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d4cba322-8a53-4f72-9e10-16388bbc5e51','f86f4002-6539-44a3-b8c9-ca6689f809c1',1684728000,'MyRacePass Data/FM Server/3Play',4.5,20.0,90.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d2180f5c-685d-4f5e-8a03-b8f6804bbf31','f86f4002-6539-44a3-b8c9-ca6689f809c1',1684814400,'FileMaker Troubleshooting/Maintenance',2.5,20.0,50.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('7feefdb6-1a66-439c-8013-a354d7af4284','f86f4002-6539-44a3-b8c9-ca6689f809c1',1684900800,'New graphics suite',5.5,20.0,110.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d23b7924-9acc-48c4-9d09-067b6f12c0b6','f86f4002-6539-44a3-b8c9-ca6689f809c1',1685073600,'TV Lineups program',7.0,20.0,140.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('6bd45327-8f01-4dab-8a92-9b76363ce2d3','f86f4002-6539-44a3-b8c9-ca6689f809c1',1685160000,'Race Day',7.0,20.0,140.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('77d6c533-8d6b-437f-b3bb-7f51dd8f8e5b','f86f4002-6539-44a3-b8c9-ca6689f809c1',1685505600,'PC Maintenance',5.5,20.0,110.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d83da575-2e45-4dd6-bf39-4f1b553a3d4f','f86f4002-6539-44a3-b8c9-ca6689f809c1',1685592000,'Web Development/CMS backend update',6.0,20.0,120.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('9dfc99e1-87b2-4487-90e2-3d7410bf771f','f86f4002-6539-44a3-b8c9-ca6689f809c1',1685592000,'Equipment Purchase - Black Box',1.0,170.0,170.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f0ed5f32-fa79-43d8-bbb1-02859b9a9f7d','f86f4002-6539-44a3-b8c9-ca6689f809c1',1685678400,'TV setup and wiring',3.0,20.0,60.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('8e754e3f-eee4-4af7-93c4-1238f32d572c','f86f4002-6539-44a3-b8c9-ca6689f809c1',1685764800,'Race Day',3.0,20.0,60.0,1752132164,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('62ea502c-a52a-462d-93ed-8deb5b8b97af','ef6a5079-2d65-46b1-8d87-a9ef5c0cb650',1685937600,'Website updates, capture card fix',5.0,20.0,100.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('97e3d7c5-a2bc-484b-90ee-8883fafd6842','ef6a5079-2d65-46b1-8d87-a9ef5c0cb650',1686024000,'Web dev',4.0,20.0,80.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c2fc47ee-9c96-4b66-9a87-fe52054ab6e7','ef6a5079-2d65-46b1-8d87-a9ef5c0cb650',1686110400,'Website work, Itinerary/Roster fixes',5.0,20.0,100.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('08aab334-1ef7-470e-b02b-99b8991cbf78','ef6a5079-2d65-46b1-8d87-a9ef5c0cb650',1686283200,'Quickbooks reinstall/drive copy (on-site)',1.0,20.0,20.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('17957359-ecdd-49be-8a23-257c7bc45e81','ef6a5079-2d65-46b1-8d87-a9ef5c0cb650',1686283200,'Web development',5.0,20.0,100.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('bc1cb3aa-3d66-4b6d-9089-6bdda101503c','ef6a5079-2d65-46b1-8d87-a9ef5c0cb650',1686369600,'Race Day',7.0,20.0,140.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('3489a122-5983-461f-bf54-edc0df82a89d','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1687492800,'Reset passwords, hide enduro points',1.5,20.0,30.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('afc213f9-5738-48a2-9be2-91264ee2fd70','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1688356800,'On-site website work',6.5,20.0,130.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('5c7c9b42-8da1-4cfe-a683-b2175588d4a0','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1688443200,'Remote website work',3.5,20.0,70.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('ee858f44-df11-4754-a105-418a0c392f5a','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1688443200,'Microsoft Office 2019 ProPlus',1.0,30.0,30.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('ddf90623-e1c7-4d77-b215-20e3bdcf057c','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1688529600,'On-site website work',6.0,20.0,120.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('62185930-638a-4de1-80f4-cb594af09848','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1688702400,'On-site website work',9.0,20.0,180.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c4a6df10-ff6b-475f-9614-3ab87bc891dc','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1688788800,'Race Day',9.0,20.0,180.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('6c7cb646-294c-4279-bd78-986b84b99c01','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1688875200,'Website work',3.0,20.0,60.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('b4575be6-65d8-435d-974f-e3a741500ba4','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1688961600,'On-site website work',7.0,20.0,140.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f2282616-a3f4-4920-9d12-c89251d67468','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1689048000,'Remote website work',6.5,20.0,130.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('ede3c1c2-d80e-489a-945d-a61e24e15f1f','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1689134400,'Remote website work',7.0,20.0,140.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('af24fb33-cde7-4bb8-a0ba-b81a9fb6222c','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1689220800,'On-site computer work',4.0,20.0,80.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('1e16ad5f-a961-46ed-a58c-4423a830839c','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1689220800,'Remote website work',4.0,20.0,80.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('b575b193-ce8e-415c-8689-6a8fac8e7a1f','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1689307200,'On-site computer/website',7.0,20.0,140.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('2f4fd87b-4a2e-4ddb-88c3-770a36bf5640','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1689393600,'Race Day',6.0,20.0,120.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('b2525223-cf5e-4a2e-a07c-ba3972f51409','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1689393600,'Acer SB220Q',1.0,80.0,80.0,1752132165,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c859a866-6487-432d-ad05-cf6bc732c6c6','a722008f-f269-4018-b755-b25cd2c5471a',1658030400,'Website (off-site)',3.0,20.0,60.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('284084d7-cb14-40db-8017-99aa6182741f','a722008f-f269-4018-b755-b25cd2c5471a',1658116800,'Website (on-site)',6.0,20.0,120.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('ea1457ef-79a9-4d36-95e9-98667ab57de4','a722008f-f269-4018-b755-b25cd2c5471a',1658203200,'Website (off-site)',4.0,20.0,80.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('64d111a0-6371-44d5-994d-afd5e47491ca','a722008f-f269-4018-b755-b25cd2c5471a',1658289600,'Move ThinkCentre/Tires',7.0,20.0,140.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('a853c17d-bc3e-45a4-88d0-48ca01631e88','a722008f-f269-4018-b755-b25cd2c5471a',1658376000,'Audience Display and Points',5.0,20.0,100.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('8118f5c5-9570-4e93-ab07-9262ca30b3bb','a722008f-f269-4018-b755-b25cd2c5471a',1658376000,'Website (off-site)',4.0,20.0,80.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('55120798-2208-48e9-b617-804e595f35e7','a722008f-f269-4018-b755-b25cd2c5471a',1658462400,'Race Day',7.0,20.0,140.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('327193a6-9393-498e-8bb3-caff95069727','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690171200,'Website work and graphics',5.0,20.0,100.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('8ff3daae-0a76-4df9-b94f-7e1aa954a3aa','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690257600,'Website backend (off-site)',3.5,20.0,70.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d2684ffe-51ab-4f20-8539-b7d1a1b76f87','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690344000,'Headshots and placeholders',5.5,20.0,110.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c9bf1a18-b45e-4c5f-abf4-34cf709fe689','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690430400,'Lineups and auth security (off-site)',4.5,20.0,90.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('97cc7b06-caa1-4a75-b1f7-3f95ab0b5e19','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690516800,'Audience display, news editor, prices',6.0,20.0,120.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('6d9aeec4-a3de-4faa-be35-feacdb39e350','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690603200,'Price editor, begin database migration',6.0,20.0,120.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('eab32b2d-9edb-4a71-ad92-11d872857be9','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690689600,'Database migration, match up 2022 reg',6.0,20.0,120.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('0c133b57-d722-47a0-b390-c7ada5e555d9','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690776000,'Begin express registration (auto) (on-site)',3.0,20.0,60.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('3e8072e3-d462-492c-a32e-5bafa12ac66d','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690776000,'Finalize express registration (off-site)',4.0,20.0,80.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('1ef0a686-5c33-4bce-892e-b72cb4f6528a','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690862400,'Champion bios, rework points for new DB',6.0,20.0,120.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('9f75cdeb-4833-4325-abdf-f392c8be311b','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1690948800,'Race Day',8.5,20.0,170.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('8ca5bc48-ae7c-458d-9ae5-da54edb580bd','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691035200,'Website hotfixes',3.0,20.0,60.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('bf316f01-6d97-4887-bbb4-7f6bc04e1075','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691121600,'Tire/Office swap, website final touches',5.5,20.0,110.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('37f86e75-a336-4f7b-ae0c-345dd584d1a1','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691208000,'Race Day/Website publish',6.0,20.0,120.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('ddedea49-53f5-4d91-913d-48156ac2b4cc','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691380800,'Database fixes (on-site)',3.5,20.0,70.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('a1315055-0a28-4a70-9701-433201cd4870','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691380800,'Draft wall of champions page',3.0,20.0,60.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('4e858515-b73a-411e-90ca-605b396c7d9c','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691553600,'Webcam/Wall of champions (on-site)',6.0,20.0,120.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('e8121138-1871-41f0-8904-0f43ce5e4690','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691553600,'Wall of Champions Finalize/Publish',4.0,20.0,80.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('480dca64-e20f-43a9-8b6c-77acd8902f3d','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691640000,'Migrate to managed DB/Hall of Fame',7.5,20.0,150.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('34d04265-a18f-4ec7-9031-7141fe411c28','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691726400,'WiFi install, laptop setup, Add results to site',4.5,20.0,90.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('ce8e3f34-5c0b-4e51-80f9-13ef76a05e74','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691812800,'Race Day',5.0,20.0,100.0,1752132166,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('771c15eb-0062-43ca-9a72-cc76069cd02a','e18f8253-59a5-45ab-9070-8397930c8e12',1692676800,'Points repair',1.0,20.0,20.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('dfe1d780-2ce9-4aa3-bafe-692dfc5e4e3f','e18f8253-59a5-45ab-9070-8397930c8e12',1692936000,'Add JuiceBox division to site',2.0,20.0,40.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d925981d-a0cc-4902-83e4-72cea6400014','e18f8253-59a5-45ab-9070-8397930c8e12',1693022400,'Prep site for ISP300 ticket/reg sale',2.5,20.0,50.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('7ac101d2-21a3-423b-ae3e-d7e796cad4cb','e18f8253-59a5-45ab-9070-8397930c8e12',1693972800,'Bring up old database site',2.0,20.0,40.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('30633312-1e3a-4bd6-9e36-d56a0c455a7c','e18f8253-59a5-45ab-9070-8397930c8e12',1694750400,'Fix registration car check',1.0,20.0,20.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('88f0b570-6406-42c8-adcb-fedd96bcbd1f','e18f8253-59a5-45ab-9070-8397930c8e12',1694836800,'Implement season ID system',2.0,20.0,40.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('cbc4efa9-f68e-44f9-b535-eee608c54a9e','e18f8253-59a5-45ab-9070-8397930c8e12',1695268800,'Update website content manager',2.0,20.0,40.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('e0e20b6f-dcd0-442c-a6c1-108c8a2d4c44','e18f8253-59a5-45ab-9070-8397930c8e12',1695355200,'Add toggle to event visibility, update events',3.0,20.0,60.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('e8a37197-cf61-4e50-a6ef-f4bc16caf583','e18f8253-59a5-45ab-9070-8397930c8e12',1696305600,'Design/implement BCA month graphics',2.5,20.0,50.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('53e884d4-0e44-47ae-9de1-a08e502166d8','e18f8253-59a5-45ab-9070-8397930c8e12',1696392000,'Create special event season/reg',4.0,20.0,80.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d275522f-1659-473a-badc-70abe80aeb07','e18f8253-59a5-45ab-9070-8397930c8e12',1696478400,'Special event roster viewer',2.5,20.0,50.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('ccc37a05-5b94-4e8b-a804-167d7d86664e','e18f8253-59a5-45ab-9070-8397930c8e12',1696564800,'Add fee/payment process to special events',1.0,20.0,20.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('920b3a78-9957-4c32-b5ed-df46c297e5fc','e18f8253-59a5-45ab-9070-8397930c8e12',1696910400,'Email update (hide personal data from all)',3.0,20.0,60.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('85ff6150-7947-41d8-b408-1a816aa0fc76','e18f8253-59a5-45ab-9070-8397930c8e12',1697601600,'Update internal roster viewer for full data',2.0,20.0,40.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('8d4d28e2-db87-4a36-891c-2cee4b161bc9','e18f8253-59a5-45ab-9070-8397930c8e12',1697688000,'DB sanitization, prep for export 1099',1.5,20.0,30.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('0d68af50-8887-467a-b1d0-1071e2c479e3','e18f8253-59a5-45ab-9070-8397930c8e12',1698206400,'Add special event roster viewer to site',4.5,20.0,90.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c84b3e48-f1b6-4199-bcdd-6f8685b2774f','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1698811200,'SE roster, change theme, update events.',4.0,20.0,80.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('e9a56413-22c6-4736-8e60-d510bb2ae953','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1698897600,'SE roster visibility, live DB detection',3.0,20.0,60.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('aa720f49-b51a-437b-a413-4a9f6a4f9544','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1699070400,'CMS RosterView Update',7.0,20.0,140.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('bf2c1ba6-8d19-4280-84ce-8173b863c23c','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1699246800,'CMS Backend Redesign (OOP)',4.0,20.0,80.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('704f9d4a-27d6-4b25-af11-43ac8211959b','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1699333200,'Various DB/Roster updates/exports',2.5,20.0,50.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('2bc45810-c0bb-4150-9191-e27efa42d7c4','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1700197200,'Shopify Website Design/Setup',4.5,20.0,90.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('208cea71-e378-494d-bcff-92c19ead51b7','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1700370000,'Special Event Mail Merge',2.5,20.0,50.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('a5f209a2-a65e-4e29-a137-4381bb477327','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1700456400,'Special Event Envelope Automation',1.0,20.0,20.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('95d8a07d-a5c2-4453-9795-c35cc7fc82b3','352863b6-4bcd-4060-9aee-7a1493381646',1701752400,'Compress all images for quicker site load',2.5,20.0,50.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('02d59723-bdd7-4bba-ba74-adfa0cfc7a16','352863b6-4bcd-4060-9aee-7a1493381646',1701838800,'Begin banquet registration',3.5,20.0,70.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c58588d4-ac5b-424b-98ad-340157190c5e','352863b6-4bcd-4060-9aee-7a1493381646',1702357200,'Banquet registration database setup',2.0,20.0,40.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('407e13d3-4e35-4ec4-a9a0-95c0916193a0','352863b6-4bcd-4060-9aee-7a1493381646',1702443600,'Banquet reg stripe price generation',2.0,20.0,40.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('093d4237-83de-4d7f-9e5c-42a719726a03','352863b6-4bcd-4060-9aee-7a1493381646',1702616400,'Online store theming/UI',3.5,20.0,70.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('eaad59e0-1324-4a00-b443-d614fd56a227','352863b6-4bcd-4060-9aee-7a1493381646',1702702800,'Online store pricing/payment',2.0,20.0,40.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('36228c80-bc0a-4940-b088-4904b17899e7','352863b6-4bcd-4060-9aee-7a1493381646',1703566800,'Finalize banquet registration',5.5,20.0,110.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('a694d9db-b50c-4863-b552-c80b19f53222','352863b6-4bcd-4060-9aee-7a1493381646',1703653200,'Update champions and win tallys',2.0,20.0,40.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('22612fae-9421-40d9-900e-643638ca7531','352863b6-4bcd-4060-9aee-7a1493381646',1703826000,'Show prev rosters, add announcements',4.5,20.0,90.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('b39974ea-cd78-4271-b6f0-60c9b8c4911c','352863b6-4bcd-4060-9aee-7a1493381646',1703912400,'CMS banquet roster visibility',4.0,20.0,80.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('06bc2406-bbbe-4daa-96d7-d80151aa41e0','352863b6-4bcd-4060-9aee-7a1493381646',1704171600,'Hide registration for fixes, refund users',3.0,20.0,60.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('1adee95a-f05a-4f2c-b648-ee1af13ed1ff','352863b6-4bcd-4060-9aee-7a1493381646',1704517200,'Convert to store-pay-update for 2024 reg',3.0,20.0,60.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('066c172c-debd-4227-bbc1-0e4eb8d4d74e','352863b6-4bcd-4060-9aee-7a1493381646',1704603600,'Finalize and publish 2024 registration',5.0,20.0,100.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('3ae082a5-3e30-401c-8757-29306ae32dae','352863b6-4bcd-4060-9aee-7a1493381646',1704776400,'New events editor, disable letters for 2024',7.0,20.0,140.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('2b5c2d59-4611-4f69-8859-3f7e7d3b294e','352863b6-4bcd-4060-9aee-7a1493381646',1704862800,'Rules uploader',8.0,20.0,160.0,1752132167,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('05304002-9b6c-423b-bbee-4637d67041a5','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1704949200,'In-person Track Day',7.5,20.0,150.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('fd6e3b70-9198-4aa2-be41-f2186bfeb52a','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1705035600,'Banquet export and mail merge',2.0,20.0,40.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('4f662168-ed58-4fa5-99ae-d79eeeae201e','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1705208400,'Number reservations',1.0,20.0,20.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('90373154-537e-43a0-82a8-fcc036514461','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1705381200,'Division page hotfix',0.5,20.0,10.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('67fc6ed2-e430-4e28-90dc-c40bd7c2e3b4','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1705467600,'Auto display driver registrations',3.0,20.0,60.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('6d680a14-f864-4047-8c3b-ff6afcdaf10c','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1705640400,'Shopify Finances',2.0,20.0,40.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c7ca7f6d-bba8-436f-9ecc-13b7e67993c7','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1705813200,'Banquet Mail Merge pt.2',2.0,20.0,40.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('18154b9a-0377-48ee-b3b6-64e0aafa45ff','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1705813200,'Banquet ticket close/clean up',1.0,20.0,20.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('6f2849cc-65d9-44fe-8b12-82c551fa71a2','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1706245200,'Take down schedule, fix event publisher',4.0,20.0,80.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f0a627d2-5f3a-4a9c-ab54-f7da5a304b00','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1706590800,'Permissions, sponsor links',2.0,20.0,40.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f0349c88-43ff-4dba-9cfe-5940713b1612','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1707022800,'Begin new roster viewer/editor',5.0,20.0,100.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('fcafdfbb-6f33-44d0-8044-4450b772b061','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1707109200,'Roster editor UI/Tables',4.0,20.0,80.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f3b99670-1f60-4e87-bb80-95170ddd784f','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1707368400,'Roster editor,change participants/autofill',4.5,20.0,90.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('10134ca6-0e8a-4c41-a91b-13945a12a4cb','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1707454800,'Roster editor,Auto tax form generation',6.5,20.0,130.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('a3b9aaec-8ba1-49fc-b1a9-7506fd84460a','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1707627600,'Update CMS navigation',3.0,20.0,60.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('52460b23-e519-4fc6-ac89-46576070f9f3','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1707714000,'CMS User Manager/Perms editor',5.0,20.0,100.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c80150ba-34fb-4b9b-a9a5-78024e7b5e40','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1708837200,'NASCAR Reg Link, general typos',0.5,20.0,10.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('4c88ca6d-482e-489c-9da9-16fa2cc8bd00','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1710129600,'Track day',4.0,20.0,80.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('5c8995f6-b191-4ba6-b129-0537785e156e','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1710216000,'Event page custom links',2.0,20.0,40.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('0b2c3bc2-be20-4c16-b384-9d5bd1e2e693','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1710388800,'Track Day',3.5,20.0,70.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('78553d71-aa77-4791-8ec1-0d2b43973308','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1710475200,'Remote Onedrive Support',1.0,20.0,20.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('e7902386-266d-4b8a-85ce-47851e181d02','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1710907200,'Data collection/analysis for site',2.5,20.0,50.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('23c59227-54d9-43ad-9b34-a554b52ba74f','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1711339200,'Driver 1099/W-9 generation update',5.0,20.0,100.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d18e53f0-ead0-4b56-b56c-be2b7671e7ea','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1711425600,'Itinerary search/export',2.0,20.0,40.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('51f1ebdd-f68b-40c6-83b7-d3b413882360','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1711512000,'Itinerary resend, Reg data export/merge',3.0,20.0,60.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f4145173-a276-458c-a8d3-c8b94b5c4cf5','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1711598400,'Fix itinerary missing from website',3.0,20.0,60.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('6dd9e3c8-7def-48a2-840b-a72de7e1c753','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1711944000,'Roster/Itinerary updates',2.0,20.0,40.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('61b692a2-8c63-4061-9f35-30844a2cedd1','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1712548800,'Roster download link',1.0,20.0,20.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('a571bef7-b402-4316-b4db-209679d67fed','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1712721600,'Roster phone number export patch',2.5,20.0,50.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('38290567-dc1f-420c-8a74-1fda829e218d','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1712808000,'Stripe support contact/ticket',3.0,20.0,60.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('5835c23b-3872-45e8-b7fc-1e9884313a26','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1713153600,'Credit card charge match with stripe',1.5,20.0,30.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f7e8504f-a95c-4921-9859-6f5c0687b1ad','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1714017600,'Exit cleanup/account reassignment',3.0,20.0,60.0,1752132168,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('02602261-24d0-4546-88da-ff9fb14c3eed','1942364d-df4e-4175-8210-dbc202ca1038',1733979600,'Begin racehub-next development',4.5,25.0,112.5,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c6334fbb-6892-4760-a61c-5cdc04921c72','1942364d-df4e-4175-8210-dbc202ca1038',1734066000,'Migrate basic features, authentication',4.0,25.0,100.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('33957386-0976-4800-a01d-2a5977e8df2a','1942364d-df4e-4175-8210-dbc202ca1038',1734498000,'Logistics planning and roadmap',1.0,25.0,25.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('dacda1bb-a445-4cdc-bdc5-db3bd1f48de1','1942364d-df4e-4175-8210-dbc202ca1038',1734670800,'Change racehub-php season, begin DB',3.0,25.0,75.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d5bddee3-1892-4c7b-bab9-50598fcf7d83','1942364d-df4e-4175-8210-dbc202ca1038',1734757200,'Events page integration, rich homepage',5.5,25.0,137.5,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('3d80aba0-53d9-40d9-a163-5dc6aff36320','1942364d-df4e-4175-8210-dbc202ca1038',1734930000,'Create news page, optimize loading flow',5.0,25.0,125.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f21fba61-87c1-4426-af99-450e42c193f5','1942364d-df4e-4175-8210-dbc202ca1038',1735016400,'Begin DigitalOcean provisioning/deploy',2.5,25.0,62.5,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('86ec004d-cc98-4c37-9943-1a7f60170d69','1942364d-df4e-4175-8210-dbc202ca1038',1735189200,'Deploy app/DB, news page optimizations',6.0,25.0,150.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('ab7cc962-5024-48e6-979a-885ccf6a7194','1942364d-df4e-4175-8210-dbc202ca1038',1735275600,'Fix deployment issues, integrate DO App',6.0,25.0,150.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('08d9e276-6dcf-4e50-9de5-dd13b580fe6f','1942364d-df4e-4175-8210-dbc202ca1038',1735362000,'Add image compression, content delivery',6.0,25.0,150.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('6a243898-3ef9-47f0-9008-9e3fca0a1c33','1942364d-df4e-4175-8210-dbc202ca1038',1735448400,'Announcements, Promo, Sponsors CMS',6.0,25.0,150.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('23059eac-34fb-44d2-9c36-1e10e387167d','1942364d-df4e-4175-8210-dbc202ca1038',1704171600,'Begin competitors page',3.0,25.0,75.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('9758e516-7655-4410-9f12-b069326ff3e2','1942364d-df4e-4175-8210-dbc202ca1038',1704258000,'Migrate APIs to tRPC for data security',4.0,25.0,100.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('628da274-f479-4603-bde2-9556795a6d4d','1942364d-df4e-4175-8210-dbc202ca1038',1704344400,'Recreate articles CMS for rich text',6.0,25.0,150.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('12a7c5ef-4029-410f-b176-a52966015698','1942364d-df4e-4175-8210-dbc202ca1038',1704430800,'Migrate announcements editor, add raindate',4.0,25.0,100.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('bea4d148-602b-4cc6-a1a9-4b9a7717c050','1942364d-df4e-4175-8210-dbc202ca1038',1704517200,'Discuss and plan out site scope (In-person)',2.0,25.0,50.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d286c494-64c4-4eaf-9a8d-5ad681b4413b','1942364d-df4e-4175-8210-dbc202ca1038',1704517200,'Implement reports, rules, and champs',6.0,25.0,150.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d43cedf4-a854-44ef-8853-725369212bd6','1942364d-df4e-4175-8210-dbc202ca1038',1704603600,'Add CMS authentication, route protection',6.5,25.0,162.5,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('fb557beb-9912-4e63-b883-8ff74451062b','1942364d-df4e-4175-8210-dbc202ca1038',1704690000,'Clean up deployment, fix UI/display bugs',3.0,25.0,75.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('1383248a-2301-4df4-985d-042cd44c1c49','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1736398800,'Correct rain date and sponsor editor saves',3.0,25.0,75.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('dff81591-7781-45a2-b7b4-2e729c15048b','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1736485200,'Fix bugs with article editor and images',4.0,25.0,100.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('01f8a30d-e04e-4ccc-ad18-da918e677ff9','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1736571600,'Add upload event image/compress for load',3.0,25.0,75.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d2f51448-c17c-4dc1-bfb3-09f7af3f9d3a','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1736744400,'Work w/ hotlap to get registration roster',2.0,25.0,50.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('70f9a81a-a4c6-4c78-b80e-0b5a6b0123a0','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1736830800,'Add user management w/ email pwd reset',4.0,25.0,100.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('b06d988c-abb5-40a7-baad-f35878cf11e9','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1736917200,'Finalize code for public, deploy site, bkp old',6.5,25.0,162.5,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('b6ef7b4b-f43a-472d-abbb-49031e268e88','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737003600,'Add analytics for page views and clicks',4.0,25.0,100.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('4081c2cd-2af2-4283-9e37-5992557666c7','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737090000,'Track System Setup/Shopify (In-person)',3.0,25.0,75.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('29a894b6-46c4-4a01-a7c8-4ebe0fc9c0cd','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737176400,'Begin real-time banquet voting system',4.0,25.0,100.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('b8a10fea-3e9b-4885-ae1c-ef222a6584e4','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737435600,'extract/export W9 information for 2024',4.5,25.0,112.5,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d78e4072-375c-41e2-8a81-69b7380b9d30','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737522000,'Implement 2024 roster for voting',3.0,25.0,75.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('735d00db-dd71-48a2-81dc-d4ab34dc3733','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737608400,'Test and complete deployment of voting',4.5,25.0,112.5,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('aa9f9359-04a1-4b47-8515-dec844564502','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737694800,'Push and enable banquet voting, fix bugs',3.0,25.0,75.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('881a19bf-b655-407d-9a52-1639ce13c5fe','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737781200,'Remove banquet voting, show points tables',3.0,25.0,75.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c07aed16-5c22-4fde-9476-b8a8a7485572','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737867600,'CMS Reconfiguration for SS and MS class',2.5,25.0,62.5,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('726427ac-a5f0-4c05-9efd-0402fa6e30f0','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737954000,'Competitors and division page redesign',5.0,25.0,125.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('874a1159-df26-4851-8dc5-d34509b25e77','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1738040400,'Browser conflict tests and fixes',3.5,25.0,87.5,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('b0fb99d3-9c32-4729-89ce-7aab0ba98256','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1738299600,'Rules CMS Editor upload and edit repair',4.5,25.0,112.5,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('33cda6b9-cdc6-4211-a52f-a6aa9badaf2f','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1738558800,'Migrate backup from BB to DO, sys updates',4.0,25.0,100.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d1dfaa3d-c880-47c4-b2a8-5e1c61b72ae0','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1738645200,'Create and verify backup scripts',3.0,25.0,75.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('21627b66-05b7-472e-8df6-ddc37554bf3b','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1738731600,'Optimize devenv to use locally hosted S3/DB',2.0,25.0,50.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('271561b0-b8af-4603-aa43-49ba87bc4da6','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1738904400,'Verify integrity of backup change chunks',1.0,25.0,25.0,1752132169,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f4d05559-46e7-46da-8cf0-00606e63fb49','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1741150800,'Limit event display, update event layout',3.0,25.0,75.0,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('b7a66b38-1628-46cd-be21-0d9d0f7c105a','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1741579200,'Work w/ cloudflare to inc. file size limit',1.0,25.0,25.0,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('8a98405f-ff6b-4e64-83aa-25cf2ad0e3cb','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742097600,'update/fix article saving/loading process',4.0,25.0,100.0,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('80c17c33-bc48-44e3-b358-73dc7df0b63e','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742184000,'update/fix rule saving/loading process',2.5,25.0,62.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d8f3066a-ea93-4221-8d9e-1921fb31d006','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742270400,'patch Next.JS emerg. security vulnerability',3.0,25.0,75.0,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('41c658b0-8020-4471-9e8d-e0f67108c9a9','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742356800,'Update server headers to use new limit',1.5,25.0,37.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('933bad4e-f7da-452c-b8f5-be6d631cbe23','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742702400,'Add PDF export of events/rules on demand',3.0,25.0,75.0,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d204cb6a-be0e-4ee7-8c46-a7f532c7a291','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742788800,'Add file caching to save $ on server usage',3.0,25.0,75.0,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('7e172229-4a68-482c-b429-326e228d185e','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742875200,'Add video upload, begin driver testimonial',4.0,25.0,100.0,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('19c8f1e4-e676-40c2-ba1a-c370c2491af8','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742961600,'Disable points section, prep for new points',2.5,25.0,62.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('1c26eeb4-22de-47bd-a170-d003fda1a213','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1743048000,'Finalize testimonial, update/enable points',5.0,25.0,125.0,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c5aaf396-c27b-44ac-b141-c69872d87a4d','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1743393600,'Retrieve and display previous itineraries',1.5,25.0,37.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('df365fb2-9d75-4589-83e4-48969e62df5d','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1746244800,'Lineups upload interface finalized/pushed',4.0,25.0,100.0,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('0a35f2d9-15b1-4d82-9ba1-df27f0024f6f','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1746331200,'Lineup audience display',3.75,25.0,93.75,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('6d6502d2-0f0d-4521-8943-4ae78e5bc7d9','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1746417600,'Lineup mobile display',4.5,25.0,112.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('258f123f-f80b-4920-af38-08bc8d163f5e','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1746849600,'Begin points upload system backend',2.5,25.0,62.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c517d481-9741-4283-b74c-e61b500cfd2c','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1747108800,'FileMaker points parsing logic',2.0,25.0,50.0,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('4e5e6815-6b4b-4433-8a52-dafcbcdd7284','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1747195200,'Update spectator policy system',1.5,25.0,37.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('fa70e962-0678-4593-b8fd-8abab5a26c6b','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1747454400,'Restructure lineup page logic for old phone',3.5,25.0,87.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('9e1878f9-f485-408e-91c9-281b02737d3e','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1747540800,'Handle cross time zone errors w/ lineups',2.5,25.0,62.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('8effbfe4-1434-4448-b7a6-5ab316fc93f9','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1747800000,'Crate mod points issue fix',1.0,25.0,25.0,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('12ea8e17-eac6-42b4-aa22-3981003172a5','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748404800,'In person, website/network planning',4.5,25.0,112.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c8bd20a4-19d0-47ca-b381-93bd6e5fd2dc','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748491200,'Rain date API integration/management',5.0,25.0,125.0,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('02ce3809-d900-4f1f-9400-64b225d61339','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748577600,'Begin lineup patches for visibility',4.5,25.0,112.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('8f4d886d-8d3d-4a30-bec7-b41ee854f731','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748664000,'Bind rain dates to events, show reschedule',5.5,25.0,137.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('6fce0331-9208-408f-8369-4fb4a2fb2fa4','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748750400,'drag and drop lineups, divisions cms update',4.5,25.0,112.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('6f49db90-fa25-44ab-9ef4-57c00c9c36c3','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748836800,'home page reordering, QoL improvements',4.5,25.0,112.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c63d9d91-6e0c-48f8-b2f1-a02c4839848c','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748923200,'In person, bulk email system',3.5,25.0,87.5,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('61b3faf1-4edc-4b05-9914-45fa8b49b51f','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748923200,'Remote, bulk email/delta points',3.0,25.0,75.0,1752132170,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c236b466-5706-4bad-8324-5219c17dd2f2','06c43197-9685-4116-b83b-1c76840905ab',1652500800,'Replay Operator',10.0,40.0,400.0,1752132902,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('772bdeaa-a5a9-4c7e-8a54-d02b6d115e16','a66739ec-fbfe-4871-8388-0b34b2228889',1683777600,'Install and configure tech PCs',2.0,20.0,40.0,1752132902,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d88ffb8e-4c29-4882-8dff-dd2d227b1639','a66739ec-fbfe-4871-8388-0b34b2228889',1683950400,'Tire shack sales/maintenance',2.0,20.0,40.0,1752132902,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('66eeb92c-ecf3-46c1-b6f6-6569b90fe598','a66739ec-fbfe-4871-8388-0b34b2228889',1684123200,'Tire program/scanning',1.0,20.0,20.0,1752132902,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('8f00b60d-5dc7-4f19-ad3e-2a51d1c4d296','d6a1da99-d066-4993-b907-1e30a769f107',1743652800,'Correct time-zone errs for non-EST viewers',2.0,25.0,50.0,1752274548,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('3ab632b7-cebc-49a0-8f59-9f39db3c9543','d6a1da99-d066-4993-b907-1e30a769f107',1743912000,'WiFi Setup/Security Updates across sites',2.0,25.0,50.0,1752274548,1);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('a3cc32fd-a0aa-4986-8ec6-91e6572ed13d','d6a1da99-d066-4993-b907-1e30a769f107',1744084800,'Standardize date handling, data utility upd.',3.5,25.0,87.5,1752274548,2);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f5009d53-27e0-4104-bde3-afaeb4c924e7','d6a1da99-d066-4993-b907-1e30a769f107',1744776000,'Rephrase/reorganize home page',2.5,25.0,62.5,1752274548,3);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d92fb22e-e0fb-4e82-b2f9-27f8eee5a150','d6a1da99-d066-4993-b907-1e30a769f107',1744862400,'Add ability to remove/submit null timeslots',3.0,25.0,75.0,1752274548,4);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('a2228d31-0c4c-49f7-ba7c-a09eb4dfe2c5','d6a1da99-d066-4993-b907-1e30a769f107',1744948800,'Hostway email contact investigate/upload',2.5,25.0,62.5,1752274548,5);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('c5dcc389-3fea-4cfa-98eb-2130016be99a','d6a1da99-d066-4993-b907-1e30a769f107',1745035200,'Re-render live schedule, update deps.',4.0,25.0,100.0,1752274548,6);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('73386e72-750e-4eb1-83de-e239c66102fe','d6a1da99-d066-4993-b907-1e30a769f107',1745467200,'Add rich text editor to site backend',3.5,25.0,87.5,1752274548,7);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('87aa98a8-131d-49bb-98fb-0460a8dde4ab','d6a1da99-d066-4993-b907-1e30a769f107',1745553600,'Update mobile view, fix rules pagination',2.0,25.0,50.0,1752274548,8);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('6fe10405-029e-4164-b918-f521d3830818','d6a1da99-d066-4993-b907-1e30a769f107',1745812800,'Lineups backend port from racehub-php',2.0,25.0,50.0,1752274548,9);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('62f2594f-0d24-405a-989c-2fcb5392a3e6','d6a1da99-d066-4993-b907-1e30a769f107',1745899200,'Update filemaker, add csv export/import',2.5,25.0,62.5,1752274548,10);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('208eebce-58e5-4d1a-8088-47a516fe39c9','d6a1da99-d066-4993-b907-1e30a769f107',1745985600,'Wireframe/basic lineups user interface',3.5,25.0,87.5,1752274548,11);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('cf1c9e48-bf50-4083-b482-9338a3c439d0','0c9a6715-70f8-4f83-ab01-a8340773431d',1749096000,'Enhance PointsUpload page',3.5,25.0,87.5,1752278188,0);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('212d7b08-2d12-449a-a0f9-c4496819b740','0c9a6715-70f8-4f83-ab01-a8340773431d',1749441600,'Handle ties in points section',3.5,25.0,87.5,1752278188,1);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('0d6d372f-6679-4dea-b78b-03ef0192c1e4','0c9a6715-70f8-4f83-ab01-a8340773431d',1749528000,'Add manipulation of bulk email contact lists',4.0,25.0,100.0,1752278188,2);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('58dfc4ef-8498-4630-a62f-b5fd20410e6e','0c9a6715-70f8-4f83-ab01-a8340773431d',1749614400,'Add staff list to email system, create new',3.5,25.0,87.5,1752278188,3);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('513c952b-c0f7-49ee-948d-41e5ca4d6e83','0c9a6715-70f8-4f83-ab01-a8340773431d',1749700800,'Add rain banner functionality to events',4.0,25.0,100.0,1752278188,4);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('469256a8-8335-48ce-a001-67928accf01c','0c9a6715-70f8-4f83-ab01-a8340773431d',1750046400,'Social Media code of conduct',2.0,25.0,50.0,1752278188,5);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('30720638-2128-4017-897a-8d635d541246','0c9a6715-70f8-4f83-ab01-a8340773431d',1750219200,'Active status management, event cleanup',3.75,25.0,93.75,1752278188,6);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('d403fc8d-72d3-4d75-a91e-9b3cf68df820','0c9a6715-70f8-4f83-ab01-a8340773431d',1750305600,'Google/Apple Calendar Sync from events',4.5,25.0,112.5,1752278188,7);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('217f013d-861a-406e-bd8e-392659f6ba72','0c9a6715-70f8-4f83-ab01-a8340773431d',1750392000,'In person, printers/email/server updates',5.0,25.0,125.0,1752278188,8);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('52be1c1f-3523-4bc3-a8ab-66902db5e229','0c9a6715-70f8-4f83-ab01-a8340773431d',1750478400,'Race day, Server/Handicapping',6.0,25.0,150.0,1752278188,9);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('dee51491-b6b1-4038-a641-d4fcdfe42f95','0c9a6715-70f8-4f83-ab01-a8340773431d',1750651200,'Repair sponsors/Plan out permissions',3.5,25.0,87.5,1752278188,10);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('0bd1bec4-2541-42db-ae38-d86d9bac43d5','0c9a6715-70f8-4f83-ab01-a8340773431d',1750737600,'Backend permissions implementation',5.5,25.0,137.5,1752278188,11);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('dbcb12d5-9b37-4f65-9275-56d82338601b','0c9a6715-70f8-4f83-ab01-a8340773431d',1750824000,'Frontend permissions/deployment',5.0,25.0,125.0,1752278188,12);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('2899f8ae-6f76-4f32-8350-09151b3d76ab','0c9a6715-70f8-4f83-ab01-a8340773431d',1750910400,'Plan out and begin migration to races sys',4.5,25.0,112.5,1752278188,13);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('f6f46a67-83ac-4bb1-b128-82daf0063128','0c9a6715-70f8-4f83-ab01-a8340773431d',1750996800,'Replace eventDivisions with races',5.0,25.0,125.0,1752278188,14);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('56e676ae-3de1-4039-b3d6-e5da99c5aa0c','0c9a6715-70f8-4f83-ab01-a8340773431d',1751083200,'In person, race day, media, development',8.0,25.0,200.0,1752278188,15);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('71fb8bc8-ac75-426b-a624-83bbaebbac1c','0c9a6715-70f8-4f83-ab01-a8340773431d',1751169600,'User interface for race editing',5.5,25.0,137.5,1752278188,16);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('eb64faf3-2a9b-4f66-8dd9-4f39f6a7af05','0c9a6715-70f8-4f83-ab01-a8340773431d',1751256000,'Public user interface for finishes and lineup',5.5,25.0,137.5,1752278188,17);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('79b80323-6c8a-4562-a274-f9e697b1efe4','0c9a6715-70f8-4f83-ab01-a8340773431d',1751342400,'Production push pt.1',6.0,25.0,150.0,1752278188,18);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('cd84469d-f608-4edd-9121-4366041fe25a','0c9a6715-70f8-4f83-ab01-a8340773431d',1751428800,'Production database migration',3.0,25.0,75.0,1752278188,19);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('93d21511-d3f9-4338-8eb4-3233614c4ae0','0c9a6715-70f8-4f83-ab01-a8340773431d',1751774400,'Testing, data entry from old races begin',4.0,25.0,100.0,1752278188,20);
|
|
||||||
INSERT INTO beenvoice_invoice_item VALUES('e701eb75-8ce0-4194-812a-2a3520487a00','0c9a6715-70f8-4f83-ab01-a8340773431d',1751860800,'Update pricing queries, 2023 races',4.0,25.0,100.0,1752278188,21);
|
|
||||||
CREATE TABLE `beenvoice_invoice` (
|
|
||||||
`id` text(255) PRIMARY KEY NOT NULL,
|
|
||||||
`invoiceNumber` text(100) NOT NULL,
|
|
||||||
`clientId` text(255) NOT NULL,
|
|
||||||
`issueDate` integer NOT NULL,
|
|
||||||
`dueDate` integer NOT NULL,
|
|
||||||
`status` text(50) DEFAULT 'draft' NOT NULL,
|
|
||||||
`totalAmount` real DEFAULT 0 NOT NULL,
|
|
||||||
`notes` text(1000),
|
|
||||||
`createdById` text(255) NOT NULL,
|
|
||||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
|
||||||
`updatedAt` integer, `taxRate` real NOT NULL DEFAULT 0, `businessId` text(255),
|
|
||||||
FOREIGN KEY (`clientId`) REFERENCES `beenvoice_client`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('76d570fe-bfec-47bd-a7fa-b4ee8133c78e','INV-20210417-131231','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1618617600,1621209600,'paid',220.0,'Imported from CSV: 2021-04-17.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132158,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('61c3d28c-5031-4372-86e3-5bf895411046','INV-20210508-131255','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1620432000,1623024000,'paid',320.0,'Imported from CSV: 2021-05-08.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132159,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('57fcd73a-0876-4e91-9856-0f9c9695fcd1','INV-20210605-131278','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1622851200,1625443200,'paid',300.0,'Imported from CSV: 2021-06-05.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132159,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('555b1f8c-7a57-427f-bea8-ee4a3f2a2bf2','INV-20210714-131301','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1626220800,1628812800,'paid',510.0,'Imported from CSV: 2021-07-14.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132159,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('4fb5d8be-2588-4187-955d-e7643b08619f','INV-20210807-131324','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1628294400,1630886400,'paid',280.0,'Imported from CSV: 2021-08-07.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132160,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('f48104da-1baa-4a70-9d0c-c03f4017f60d','INV-20210825-131337','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1629849600,1632441600,'paid',450.0,'Imported from CSV: 2021-08-25.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132160,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('d0c7c941-d0e4-4d55-b1e5-10ed71f1c9f5','INV-20210921-131348','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1632182400,1634774400,'paid',340.0,'Imported from CSV: 2021-09-21.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132160,1752133428,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('6c4314c7-7bc7-4d8a-9513-59a1ebcfd890','INV-20211201-131360','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1638316800,1640908800,'paid',200.0,'Imported from CSV: 2021-12-01.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132161,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('b018eaca-b4b1-4c96-8e40-2a1ab5211e48','INV-20220422-131373','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1650585600,1653177600,'paid',250.0,'Imported from CSV: 2022-04-22.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132161,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('a0da2a05-5681-46fd-b988-235ec24971e2','INV-20220514-131387','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1652486400,1655078400,'paid',200.0,'Imported from CSV: 2022-05-14.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132162,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('713a368a-f7de-4de8-95dd-2a4a2d626fa1','INV-20220521-131401','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1653091200,1655683200,'paid',540.0,'Imported from CSV: 2022-05-21.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132162,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('fac3b7e2-9816-459c-960e-ac520b3f2cd5','INV-20220607-131419','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1654560000,1657152000,'paid',460.0,'Imported from CSV: 2022-06-07.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132162,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('8704d2fe-8972-4dae-8062-2f5b81e14493','INV-20220630-131436','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1656547200,1659139200,'paid',600.0,'Imported from CSV: 2022-06-30.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132163,1752133428,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('babfc847-b37d-44f2-91a9-4251691c11b4','INV-20220731-131453','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1659225600,1661817600,'paid',820.0,'Imported from CSV: 2022-07-31.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132163,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('89f677fb-ca0f-4d43-9547-d4da77f0f0ba','INV-20230316-131472','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1678924800,1681516800,'paid',520.0,'Imported from CSV: 2023-03-16.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132163,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('2a07bf2e-1923-4b4b-aba9-14c507a2f2c4','INV-20230513-131490','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1683936000,1686528000,'paid',750.0,'Imported from CSV: 2023-05-13.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132164,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('0b057a65-fe7d-4495-8756-4dd61f6895e1','INV-20230521-131513','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1684627200,1687219200,'paid',790.0,'Imported from CSV: 2023-05-21.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132164,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('f86f4002-6539-44a3-b8c9-ca6689f809c1','INV-20230604-131532','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1685836800,1688428800,'paid',1050.0,'Imported from CSV: 2023-06-04.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132164,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('ef6a5079-2d65-46b1-8d87-a9ef5c0cb650','INV-20230611-131552','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1686441600,1689033600,'paid',540.0,'Imported from CSV: 2023-06-11.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132165,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb','INV-20230709-131574','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1688860800,1691452800,'paid',800.0,'Imported from CSV: 2023-07-09.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132165,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('9186435f-2b62-4c58-aa45-c00aeac9c7d6','INV-20230717-131599','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1689552000,1692144000,'paid',910.0,'Imported from CSV: 2023-07-17.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132165,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('a722008f-f269-4018-b755-b25cd2c5471a','INV-20230722-131624','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1689984000,1692576000,'paid',720.0,'Imported from CSV: 2023-07-22.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132166,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('ed3cf514-1438-4ee0-8e72-3f47c0f9aa15','INV-20230801-131649','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1690848000,1693440000,'paid',990.0,'Imported from CSV: 2023-08-01.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132166,1752133428,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('c7e84ee9-ae1e-4f31-b120-6cc7e02b0442','INV-20230812-131677','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1691798400,1694390400,'paid',1130.0,'Imported from CSV: 2023-08-12.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132166,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('e18f8253-59a5-45ab-9070-8397930c8e12','INV-20231025-131707','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1698192000,1700787600,'paid',730.0,'Imported from CSV: 2023-10-25.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132167,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('f39a6380-e1c0-4a28-b25e-f960e40ebbdc','INV-20231120-131737','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1700438400,1703030400,'paid',570.0,'Imported from CSV: 2023-11-20.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132167,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('352863b6-4bcd-4060-9aee-7a1493381646','INV-20240110-131769','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1704844800,1707436800,'paid',1150.0,'Imported from CSV: 2024-01-10.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132167,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('dc0e0595-07a8-471b-8f7b-23cd13c0b8c1','INV-20240314-131797','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1710374400,1712966400,'paid',1190.0,'Imported from CSV: 2024-03-14.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132168,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('cf6ea6c8-c485-4a01-aa12-f68306ef426a','INV-20240425-131828','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1714003200,1716595200,'paid',660.0,'Imported from CSV: 2024-04-25.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132168,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('1942364d-df4e-4175-8210-dbc202ca1038','INV-20250108-131858','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1736294400,1738886400,'paid',2100.0,'Imported from CSV: 2025-01-08.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132169,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('547569b8-2f7c-486b-a4f1-2a7b80aa904a','INV-20250207-131897','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1738886400,1741478400,'paid',1925.0,'Imported from CSV: 2025-02-07.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132169,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('bd64542e-c576-4dd7-b0d4-f4d6077aef25','INV-20250402-131932','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1743552000,1746144000,'paid',850.0,'Imported from CSV: 2025-04-02.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132170,1752133428,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('d6a1da99-d066-4993-b907-1e30a769f107','INV-20250501-132029','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1746057600,1748649600,'paid',825.0,'Imported from CSV: 2025-05-01.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132170,1752274548,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('5a8f214c-8f6d-46e9-949e-1e9e31c40974','INV-20250604-132064','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1748995200,1751587200,'paid',1506.25,'Imported from CSV: 2025-06-04.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132170,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('0c9a6715-70f8-4f83-ab01-a8340773431d','INV-20250702-132103','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1751414400,1754006400,'sent',2481.25,'Imported from CSV: 2025-07-02.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132171,1752278188,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('06c43197-9685-4116-b83b-1c76840905ab','INV-1752132853225','8c24c053-9f84-49be-95e3-30fe9cdcdeef',1652500800,1655179200,'paid',400.0,'Imported from CSV: 2022-05-14-NBC.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132902,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
INSERT INTO beenvoice_invoice VALUES('a66739ec-fbfe-4871-8388-0b34b2228889','INV-1752132853250','81edd8a8-c5c7-4f16-ab71-0efedbe3aff7',1684641600,1687320000,'paid',100.0,'Imported from CSV: 2023-05-21-hoosier.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132902,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
|
|
||||||
CREATE TABLE `beenvoice_session` (
|
|
||||||
`sessionToken` text(255) PRIMARY KEY NOT NULL,
|
|
||||||
`userId` text(255) NOT NULL,
|
|
||||||
`expires` integer NOT NULL,
|
|
||||||
FOREIGN KEY (`userId`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
CREATE TABLE `beenvoice_user` (
|
|
||||||
`id` text(255) PRIMARY KEY NOT NULL,
|
|
||||||
`name` text(255),
|
|
||||||
`email` text(255) NOT NULL,
|
|
||||||
`password` text(255),
|
|
||||||
`emailVerified` integer DEFAULT (unixepoch()),
|
|
||||||
`image` text(255)
|
|
||||||
);
|
|
||||||
INSERT INTO beenvoice_user VALUES('1ca66210-7d70-43d1-b01b-07004f566ac8','Sean O''Connor','sean@soconnor.dev','$2b$12$ntXp5nKRyNyf9HzQFaodVO/yjKHjCW6lG0.MiIH0U74o4y15Jz0Cu',1752122289,NULL);
|
|
||||||
INSERT INTO beenvoice_user VALUES('08305460-ee86-430b-aa8b-a5280b4a1d5b','Test User','test@example.com','$2b$12$Qh7kl3I0poJCBlitIm9HeumOPCh0zRdgl161KrCyxTNeVi979Lb7C',1752122648,NULL);
|
|
||||||
CREATE TABLE `beenvoice_verification_token` (
|
|
||||||
`identifier` text(255) NOT NULL,
|
|
||||||
`token` text(255) NOT NULL,
|
|
||||||
`expires` integer NOT NULL,
|
|
||||||
PRIMARY KEY(`identifier`, `token`)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
hash text NOT NULL,
|
|
||||||
created_at numeric
|
|
||||||
);
|
|
||||||
INSERT INTO __drizzle_migrations VALUES(NULL,'01ee87b5282b51988c94170329f6261297481122c93e3c45ac216f0d9a2275f4',1752251358024);
|
|
||||||
INSERT INTO __drizzle_migrations VALUES(NULL,'6c12a89fdba3169518236b650fa5cbbaff2bff0ac67a4ee5c717295135c1b0a0',1752268902130);
|
|
||||||
CREATE TABLE IF NOT EXISTS "beenvoice_client" (
|
|
||||||
`id` text(255) PRIMARY KEY NOT NULL,
|
|
||||||
`name` text(255) NOT NULL,
|
|
||||||
`email` text(255),
|
|
||||||
`phone` text(50),
|
|
||||||
`addressLine1` text(255),
|
|
||||||
`addressLine2` text(255),
|
|
||||||
`city` text(100),
|
|
||||||
`state` text(50),
|
|
||||||
`postalCode` text(20),
|
|
||||||
`country` text(100),
|
|
||||||
`createdById` text(255) NOT NULL,
|
|
||||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
|
||||||
`updatedAt` integer,
|
|
||||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
INSERT INTO beenvoice_client VALUES('81edd8a8-c5c7-4f16-ab71-0efedbe3aff7','Hoosier Tire of Calverton','ar@riverheadraceway.com','(631) 842-7223','1797 Old Country Rd','','Riverhead','NY','11901','','1ca66210-7d70-43d1-b01b-07004f566ac8',1752129038,1752129178);
|
|
||||||
INSERT INTO beenvoice_client VALUES('1c17bccd-3bc6-42c2-a500-68728a2a9d25','Riverhead Raceway','ar@riverheadraceway.com','(631) 842-7223','1797 Old Country Rd','','Riverhead','NY','11901','United States','1ca66210-7d70-43d1-b01b-07004f566ac8',1752129251,1752129251);
|
|
||||||
INSERT INTO beenvoice_client VALUES('8c24c053-9f84-49be-95e3-30fe9cdcdeef','TDE, Inc.','tvtimd@aol.com','(413) 575-6125','116 Dowd Ct','','Ludlow','MA','01056','United States','1ca66210-7d70-43d1-b01b-07004f566ac8',1752129474,1752129474);
|
|
||||||
CREATE TABLE `beenvoice_business` (
|
|
||||||
`id` text(255) PRIMARY KEY NOT NULL,
|
|
||||||
`name` text(255) NOT NULL,
|
|
||||||
`email` text(255),
|
|
||||||
`phone` text(50),
|
|
||||||
`addressLine1` text(255),
|
|
||||||
`addressLine2` text(255),
|
|
||||||
`city` text(100),
|
|
||||||
`state` text(50),
|
|
||||||
`postalCode` text(20),
|
|
||||||
`country` text(100),
|
|
||||||
`website` text(255),
|
|
||||||
`taxId` text(100),
|
|
||||||
`logoUrl` text(500),
|
|
||||||
`isDefault` integer DEFAULT false,
|
|
||||||
`createdById` text(255) NOT NULL,
|
|
||||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
|
||||||
`updatedAt` integer,
|
|
||||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
INSERT INTO beenvoice_business VALUES('20ef93d6-b1c4-4f9a-b1c1-e62423770f6b','Sean O''Connor','sean.oconnor@riverheadraceway.com','(631) 601-6555','14 Washington Avenue','','Miller Place','NY','11764','United States','https://soconnor.dev','','',1,'1ca66210-7d70-43d1-b01b-07004f566ac8',1752277286,1752277286);
|
|
||||||
CREATE INDEX `account_user_id_idx` ON `beenvoice_account` (`userId`);
|
|
||||||
CREATE INDEX `invoice_item_invoice_id_idx` ON `beenvoice_invoice_item` (`invoiceId`);
|
|
||||||
CREATE INDEX `invoice_item_date_idx` ON `beenvoice_invoice_item` (`date`);
|
|
||||||
CREATE INDEX `invoice_client_id_idx` ON `beenvoice_invoice` (`clientId`);
|
|
||||||
CREATE INDEX `invoice_created_by_idx` ON `beenvoice_invoice` (`createdById`);
|
|
||||||
CREATE INDEX `invoice_number_idx` ON `beenvoice_invoice` (`invoiceNumber`);
|
|
||||||
CREATE INDEX `invoice_status_idx` ON `beenvoice_invoice` (`status`);
|
|
||||||
CREATE INDEX `session_userId_idx` ON `beenvoice_session` (`userId`);
|
|
||||||
CREATE INDEX `client_name_idx` ON `beenvoice_client` (`name`);
|
|
||||||
CREATE INDEX `client_email_idx` ON `beenvoice_client` (`email`);
|
|
||||||
CREATE INDEX `invoice_item_position_idx` ON `beenvoice_invoice_item` (`position`);
|
|
||||||
CREATE INDEX `client_created_by_idx` ON `beenvoice_client` (`createdById`);
|
|
||||||
CREATE INDEX `business_created_by_idx` ON `beenvoice_business` (`createdById`);
|
|
||||||
CREATE INDEX `business_name_idx` ON `beenvoice_business` (`name`);
|
|
||||||
CREATE INDEX `business_email_idx` ON `beenvoice_business` (`email`);
|
|
||||||
CREATE INDEX `business_is_default_idx` ON `beenvoice_business` (`isDefault`);
|
|
||||||
CREATE INDEX `invoice_business_id_idx` ON `beenvoice_invoice` (`businessId`);
|
|
||||||
COMMIT;
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
||||||
|
volumes:
|
||||||
|
- beenvoice_dev_pg_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
beenvoice_dev_pg_data:
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
image: beenvoice:local
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
|
||||||
|
DB_DISABLE_SSL: "true"
|
||||||
|
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||||
|
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
|
||||||
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
|
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
|
||||||
|
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
|
||||||
|
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false}
|
||||||
|
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
|
||||||
|
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
|
||||||
|
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
|
||||||
|
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-3000}:3000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
||||||
|
volumes:
|
||||||
|
- beenvoice_pg_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
beenvoice_pg_data:
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
# Enhanced Email Sending Features
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The beenvoice application now includes a comprehensive email sending system with preview, rich text editing, and confirmation features. This enhancement provides a professional email experience for sending invoices to clients.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 🎨 Rich Text Email Composer
|
||||||
|
- **Tiptap Editor Integration**: Professional rich text editing with formatting options
|
||||||
|
- **Text Formatting**: Bold, italic, strikethrough, and color options
|
||||||
|
- **Text Alignment**: Left, center, and right alignment
|
||||||
|
- **Lists**: Bullet points and numbered lists
|
||||||
|
- **Color Picker**: Choose from a variety of text colors
|
||||||
|
- **Real-time Preview**: See changes as you type
|
||||||
|
|
||||||
|
### 👁️ Email Preview
|
||||||
|
- **Visual Preview**: See exactly how your email will appear to recipients
|
||||||
|
- **Invoice Summary**: Displays key invoice details (number, date, amount)
|
||||||
|
- **Attachment Notice**: Shows PDF attachment information
|
||||||
|
- **Professional Styling**: Clean, branded email template
|
||||||
|
- **Responsive Design**: Optimized for all screen sizes with proper text wrapping
|
||||||
|
- **Mobile-First**: Touch-friendly interface with proper spacing
|
||||||
|
|
||||||
|
### ✅ Send Confirmation
|
||||||
|
- **Two-Step Process**: Compose ↔ Preview with Send Action
|
||||||
|
- **Action-Based Sending**: Send button available from sidebar and floating action bar
|
||||||
|
- **Status Updates**: Automatic status change from draft to sent
|
||||||
|
- **Error Handling**: Clear error messages with specific guidance
|
||||||
|
- **SSR Compatible**: Proper hydration handling for server-side rendering
|
||||||
|
|
||||||
|
### 📄 Smart Templates
|
||||||
|
- **Auto-Generated Content**: Professional email templates with proper paragraph spacing
|
||||||
|
- **Time-Based Greetings**: Morning, afternoon, or evening greetings
|
||||||
|
- **Invoice Details**: Automatically includes invoice number, date, and amount
|
||||||
|
- **Business Branding**: Uses your business name and contact information
|
||||||
|
- **Immediate Loading**: Content appears instantly in the editor without requiring tab switching
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### EmailComposer
|
||||||
|
**Location**: `src/components/forms/email-composer.tsx`
|
||||||
|
|
||||||
|
A rich text editor component for composing emails with formatting options.
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `subject`: Email subject line
|
||||||
|
- `onSubjectChange`: Callback for subject changes
|
||||||
|
- `content`: Email content (HTML)
|
||||||
|
- `onContentChange`: Callback for content changes
|
||||||
|
- `fromEmail`: Sender email address
|
||||||
|
- `toEmail`: Recipient email address
|
||||||
|
|
||||||
|
### EmailPreview
|
||||||
|
**Location**: `src/components/forms/email-preview.tsx`
|
||||||
|
|
||||||
|
Displays a visual preview of how the email will appear to recipients.
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `subject`: Email subject line
|
||||||
|
- `fromEmail`: Sender email address
|
||||||
|
- `toEmail`: Recipient email address
|
||||||
|
- `content`: Email content (HTML)
|
||||||
|
- `invoice`: Invoice data for summary display
|
||||||
|
|
||||||
|
### SendEmailDialog
|
||||||
|
**Location**: `src/components/forms/send-email-dialog.tsx`
|
||||||
|
|
||||||
|
Main dialog component that combines composition, preview, and confirmation.
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `invoiceId`: ID of the invoice to send
|
||||||
|
- `trigger`: React element that opens the dialog
|
||||||
|
- `invoice`: Invoice data
|
||||||
|
- `onEmailSent`: Callback when email is successfully sent
|
||||||
|
|
||||||
|
### EnhancedSendInvoiceButton
|
||||||
|
**Location**: `src/components/forms/enhanced-send-invoice-button.tsx`
|
||||||
|
|
||||||
|
Enhanced button component that opens the email dialog.
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `invoiceId`: ID of the invoice to send
|
||||||
|
- `variant`: Button style variant
|
||||||
|
- `className`: Additional CSS classes
|
||||||
|
- `showResend`: Whether to show "Resend" text
|
||||||
|
- `size`: Button size
|
||||||
|
|
||||||
|
## API Enhancements
|
||||||
|
|
||||||
|
### Enhanced Email Router
|
||||||
|
**Location**: `src/server/api/routers/email.ts`
|
||||||
|
|
||||||
|
The email API has been enhanced to support custom content and HTML emails.
|
||||||
|
|
||||||
|
**New Parameters**:
|
||||||
|
- `customSubject`: Optional custom email subject
|
||||||
|
- `customContent`: Optional custom email content (HTML)
|
||||||
|
- `useHtml`: Boolean flag to send HTML email
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- HTML email support with plain text fallback
|
||||||
|
- Custom subject lines
|
||||||
|
- Rich HTML content
|
||||||
|
- Automatic PDF attachment
|
||||||
|
- BCC to business email
|
||||||
|
- Comprehensive error handling
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
```tsx
|
||||||
|
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
|
||||||
|
|
||||||
|
// Replace existing send buttons
|
||||||
|
<EnhancedSendInvoiceButton
|
||||||
|
invoiceId={invoice.id}
|
||||||
|
className="w-full"
|
||||||
|
showResend={invoice.status === "sent"}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Dialog
|
||||||
|
```tsx
|
||||||
|
import { SendEmailDialog } from "~/components/forms/send-email-dialog";
|
||||||
|
|
||||||
|
<SendEmailDialog
|
||||||
|
invoiceId={invoice.id}
|
||||||
|
invoice={invoiceData}
|
||||||
|
trigger={<Button>Send Custom Email</Button>}
|
||||||
|
onEmailSent={() => console.log("Email sent!")}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standalone Components
|
||||||
|
```tsx
|
||||||
|
import { EmailComposer } from "~/components/forms/email-composer";
|
||||||
|
import { EmailPreview } from "~/components/forms/email-preview";
|
||||||
|
|
||||||
|
// Use individual components for custom implementations
|
||||||
|
<EmailComposer
|
||||||
|
subject={subject}
|
||||||
|
onSubjectChange={setSubject}
|
||||||
|
content={content}
|
||||||
|
onContentChange={setContent}
|
||||||
|
fromEmail="you@business.com"
|
||||||
|
toEmail="client@company.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EmailPreview
|
||||||
|
subject={subject}
|
||||||
|
content={content}
|
||||||
|
fromEmail="you@business.com"
|
||||||
|
toEmail="client@company.com"
|
||||||
|
invoice={invoiceData}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- **@tiptap/react**: Rich text editor framework
|
||||||
|
- **@tiptap/starter-kit**: Basic editor functionality
|
||||||
|
- **@tiptap/extension-text-style**: Text styling support
|
||||||
|
- **@tiptap/extension-color**: Color picker support
|
||||||
|
- **@tiptap/extension-text-align**: Text alignment options
|
||||||
|
|
||||||
|
### Email Templates
|
||||||
|
The system generates professional HTML email templates with:
|
||||||
|
- Responsive design
|
||||||
|
- Brand colors (green theme)
|
||||||
|
- Invoice summary cards
|
||||||
|
- Proper typography
|
||||||
|
- Attachment indicators
|
||||||
|
- Footer branding
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
Comprehensive error handling for:
|
||||||
|
- Invalid email addresses
|
||||||
|
- Missing client information
|
||||||
|
- Resend API issues
|
||||||
|
- Network connectivity problems
|
||||||
|
- Domain verification issues
|
||||||
|
- Rate limiting
|
||||||
|
|
||||||
|
## Usage in Application
|
||||||
|
|
||||||
|
The enhanced email functionality is integrated throughout the application:
|
||||||
|
- Invoice view pages with enhanced send buttons
|
||||||
|
- Full-page email composition interface
|
||||||
|
- Professional email templates with invoice integration
|
||||||
|
- Comprehensive preview and confirmation workflow
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### From Basic Send Button
|
||||||
|
Replace existing `SendInvoiceButton` components with `EnhancedSendInvoiceButton`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before
|
||||||
|
import { SendInvoiceButton } from "../_components/send-invoice-button";
|
||||||
|
<SendInvoiceButton invoiceId={invoice.id} />
|
||||||
|
|
||||||
|
// After
|
||||||
|
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
|
||||||
|
<EnhancedSendInvoiceButton invoiceId={invoice.id} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Compatibility
|
||||||
|
The enhanced email API is backward compatible with existing implementations. New features are opt-in through additional parameters.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- **Input Sanitization**: All user input is validated and sanitized
|
||||||
|
- **Email Validation**: Comprehensive email format validation
|
||||||
|
- **Rate Limiting**: Built-in protection against spam
|
||||||
|
- **Domain Verification**: Resend domain verification required
|
||||||
|
- **Authentication**: All email operations require valid authentication
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **SSR Optimization**: Proper server-side rendering with hydration safeguards
|
||||||
|
- **Efficient Loading**: Content initializes immediately without requiring user interaction
|
||||||
|
- **Optimized Rendering**: Efficient React component updates with proper state management
|
||||||
|
- **Caching**: Proper query caching for invoice data
|
||||||
|
- **Error Boundaries**: Graceful error handling without crashes
|
||||||
|
- **Responsive Design**: Optimized layouts for all screen sizes with text overflow prevention
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
### Send Email Page
|
||||||
|
Access the email interface by clicking "Send Invoice" on any invoice:
|
||||||
|
- `/dashboard/invoices/[id]/send` - Full-page email composition
|
||||||
|
- Two-tab interface: Compose ↔ Preview
|
||||||
|
- Send action available from sidebar and floating action bar
|
||||||
|
- Fully responsive design with proper text wrapping and overflow handling
|
||||||
|
- Professional layout with sidebar containing:
|
||||||
|
- Invoice summary (number, client, date, status)
|
||||||
|
- Email details (from, to, subject, attachment info)
|
||||||
|
- Context-aware action buttons
|
||||||
|
- Auto-filled message with proper HTML formatting and paragraph spacing
|
||||||
|
- Immediate content loading without requiring tab navigation
|
||||||
|
|
||||||
|
## Fixes and Improvements
|
||||||
|
|
||||||
|
Recent fixes and enhancements:
|
||||||
|
- **SSR Compatibility**: Fixed Tiptap hydration issues for reliable server-side rendering
|
||||||
|
- **Content Loading**: Improved email content initialization for immediate display
|
||||||
|
- **Responsive Design**: Enhanced text wrapping and overflow handling for all screen sizes
|
||||||
|
- **UI/UX**: Removed confirmation tab in favor of action-based sending approach
|
||||||
|
- **Performance**: Optimized state management for faster content loading
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Planned improvements include:
|
||||||
|
- Email templates library
|
||||||
|
- Scheduling email delivery
|
||||||
|
- Email tracking and read receipts
|
||||||
|
- Bulk email sending
|
||||||
|
- Custom email signatures
|
||||||
|
- Integration with email marketing tools
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions related to the email system:
|
||||||
|
1. Check the console for error messages
|
||||||
|
2. Verify Resend API configuration
|
||||||
|
3. Ensure client email addresses are valid
|
||||||
|
4. Review domain verification status
|
||||||
|
5. Check network connectivity
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### Version 1.0.0
|
||||||
|
- Initial release of enhanced email system
|
||||||
|
- Rich text editor integration
|
||||||
|
- Email preview functionality
|
||||||
|
- Send confirmation workflow
|
||||||
|
- HTML email support
|
||||||
|
- Professional templates
|
||||||
|
- Demo page implementation
|
||||||
+16
-10
@@ -1,17 +1,23 @@
|
|||||||
import { type Config } from "drizzle-kit";
|
import type { Config } from "drizzle-kit";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
// Load .env.local if it exists
|
||||||
|
dotenv.config({ path: ".env.local" });
|
||||||
|
// Load .env if it exists (fallback)
|
||||||
|
dotenv.config({ path: ".env" });
|
||||||
|
|
||||||
import { env } from "~/env";
|
// Use a relative import; path alias "~" may not resolve in CLI context
|
||||||
|
// import { env } from "./src/env.js";
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
throw new Error("DATABASE_URL is not set");
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
schema: "./src/server/db/schema.ts",
|
schema: "./src/server/db/schema.ts",
|
||||||
dialect: "sqlite",
|
out: "./drizzle",
|
||||||
dbCredentials: env.DATABASE_AUTH_TOKEN
|
dialect: "postgresql",
|
||||||
? {
|
dbCredentials: {
|
||||||
url: env.DATABASE_URL,
|
url: process.env.DATABASE_URL,
|
||||||
token: env.DATABASE_AUTH_TOKEN,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
url: env.DATABASE_URL,
|
|
||||||
},
|
},
|
||||||
tablesFilter: ["beenvoice_*"],
|
tablesFilter: ["beenvoice_*"],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE `beenvoice_invoice_item` ADD COLUMN `position` integer DEFAULT 0 NOT NULL;
|
|
||||||
CREATE INDEX `invoice_item_position_idx` ON `beenvoice_invoice_item` (`position`);
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
CREATE TABLE `beenvoice_account` (
|
|
||||||
`userId` text(255) NOT NULL,
|
|
||||||
`type` text(255) NOT NULL,
|
|
||||||
`provider` text(255) NOT NULL,
|
|
||||||
`providerAccountId` text(255) NOT NULL,
|
|
||||||
`refresh_token` text,
|
|
||||||
`access_token` text,
|
|
||||||
`expires_at` integer,
|
|
||||||
`token_type` text(255),
|
|
||||||
`scope` text(255),
|
|
||||||
`id_token` text,
|
|
||||||
`session_state` text(255),
|
|
||||||
PRIMARY KEY(`provider`, `providerAccountId`),
|
|
||||||
FOREIGN KEY (`userId`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `account_user_id_idx` ON `beenvoice_account` (`userId`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `beenvoice_business` (
|
|
||||||
`id` text(255) PRIMARY KEY NOT NULL,
|
|
||||||
`name` text(255) NOT NULL,
|
|
||||||
`email` text(255),
|
|
||||||
`phone` text(50),
|
|
||||||
`addressLine1` text(255),
|
|
||||||
`addressLine2` text(255),
|
|
||||||
`city` text(100),
|
|
||||||
`state` text(50),
|
|
||||||
`postalCode` text(20),
|
|
||||||
`country` text(100),
|
|
||||||
`website` text(255),
|
|
||||||
`taxId` text(100),
|
|
||||||
`logoUrl` text(500),
|
|
||||||
`isDefault` integer DEFAULT false,
|
|
||||||
`createdById` text(255) NOT NULL,
|
|
||||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
|
||||||
`updatedAt` integer,
|
|
||||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `business_created_by_idx` ON `beenvoice_business` (`createdById`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `business_name_idx` ON `beenvoice_business` (`name`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `business_email_idx` ON `beenvoice_business` (`email`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `business_is_default_idx` ON `beenvoice_business` (`isDefault`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `beenvoice_client` (
|
|
||||||
`id` text(255) PRIMARY KEY NOT NULL,
|
|
||||||
`name` text(255) NOT NULL,
|
|
||||||
`email` text(255),
|
|
||||||
`phone` text(50),
|
|
||||||
`addressLine1` text(255),
|
|
||||||
`addressLine2` text(255),
|
|
||||||
`city` text(100),
|
|
||||||
`state` text(50),
|
|
||||||
`postalCode` text(20),
|
|
||||||
`country` text(100),
|
|
||||||
`createdById` text(255) NOT NULL,
|
|
||||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
|
||||||
`updatedAt` integer,
|
|
||||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `client_created_by_idx` ON `beenvoice_client` (`createdById`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `client_name_idx` ON `beenvoice_client` (`name`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `client_email_idx` ON `beenvoice_client` (`email`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `beenvoice_invoice_item` (
|
|
||||||
`id` text(255) PRIMARY KEY NOT NULL,
|
|
||||||
`invoiceId` text(255) NOT NULL,
|
|
||||||
`date` integer NOT NULL,
|
|
||||||
`description` text(500) NOT NULL,
|
|
||||||
`hours` real NOT NULL,
|
|
||||||
`rate` real NOT NULL,
|
|
||||||
`amount` real NOT NULL,
|
|
||||||
`position` integer DEFAULT 0 NOT NULL,
|
|
||||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
|
||||||
FOREIGN KEY (`invoiceId`) REFERENCES `beenvoice_invoice`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoice_item_invoice_id_idx` ON `beenvoice_invoice_item` (`invoiceId`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoice_item_date_idx` ON `beenvoice_invoice_item` (`date`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoice_item_position_idx` ON `beenvoice_invoice_item` (`position`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `beenvoice_invoice` (
|
|
||||||
`id` text(255) PRIMARY KEY NOT NULL,
|
|
||||||
`invoiceNumber` text(100) NOT NULL,
|
|
||||||
`businessId` text(255),
|
|
||||||
`clientId` text(255) NOT NULL,
|
|
||||||
`issueDate` integer NOT NULL,
|
|
||||||
`dueDate` integer NOT NULL,
|
|
||||||
`status` text(50) DEFAULT 'draft' NOT NULL,
|
|
||||||
`totalAmount` real DEFAULT 0 NOT NULL,
|
|
||||||
`taxRate` real DEFAULT 0 NOT NULL,
|
|
||||||
`notes` text(1000),
|
|
||||||
`createdById` text(255) NOT NULL,
|
|
||||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
|
||||||
`updatedAt` integer,
|
|
||||||
FOREIGN KEY (`businessId`) REFERENCES `beenvoice_business`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`clientId`) REFERENCES `beenvoice_client`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoice_business_id_idx` ON `beenvoice_invoice` (`businessId`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoice_client_id_idx` ON `beenvoice_invoice` (`clientId`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoice_created_by_idx` ON `beenvoice_invoice` (`createdById`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoice_number_idx` ON `beenvoice_invoice` (`invoiceNumber`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoice_status_idx` ON `beenvoice_invoice` (`status`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `beenvoice_session` (
|
|
||||||
`sessionToken` text(255) PRIMARY KEY NOT NULL,
|
|
||||||
`userId` text(255) NOT NULL,
|
|
||||||
`expires` integer NOT NULL,
|
|
||||||
FOREIGN KEY (`userId`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `session_userId_idx` ON `beenvoice_session` (`userId`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `beenvoice_user` (
|
|
||||||
`id` text(255) PRIMARY KEY NOT NULL,
|
|
||||||
`name` text(255),
|
|
||||||
`email` text(255) NOT NULL,
|
|
||||||
`password` text(255),
|
|
||||||
`emailVerified` integer DEFAULT (unixepoch()),
|
|
||||||
`image` text(255)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `beenvoice_verification_token` (
|
|
||||||
`identifier` text(255) NOT NULL,
|
|
||||||
`token` text(255) NOT NULL,
|
|
||||||
`expires` integer NOT NULL,
|
|
||||||
PRIMARY KEY(`identifier`, `token`)
|
|
||||||
);
|
|
||||||
@@ -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");
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE `beenvoice_invoice` ADD COLUMN `taxRate` real NOT NULL DEFAULT 0;
|
|
||||||
UPDATE `beenvoice_invoice` SET `taxRate` = 0 WHERE `taxRate` IS NULL;
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "beenvoice_expense" ADD COLUMN "taxDeductible" boolean DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "beenvoice_user" ADD COLUMN "interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL;
|
||||||
|
ALTER TABLE "beenvoice_user" ADD COLUMN "fontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
|
||||||
CREATE TABLE `__new_beenvoice_invoice` (
|
|
||||||
`id` text(255) PRIMARY KEY NOT NULL,
|
|
||||||
`invoiceNumber` text(100) NOT NULL,
|
|
||||||
`businessId` text(255),
|
|
||||||
`clientId` text(255) NOT NULL,
|
|
||||||
`issueDate` integer NOT NULL,
|
|
||||||
`dueDate` integer NOT NULL,
|
|
||||||
`status` text(50) DEFAULT 'draft' NOT NULL,
|
|
||||||
`totalAmount` real DEFAULT 0 NOT NULL,
|
|
||||||
`taxRate` real DEFAULT 0 NOT NULL,
|
|
||||||
`notes` text(1000),
|
|
||||||
`createdById` text(255) NOT NULL,
|
|
||||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
|
||||||
`updatedAt` integer,
|
|
||||||
FOREIGN KEY (`businessId`) REFERENCES `beenvoice_business`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`clientId`) REFERENCES `beenvoice_client`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
INSERT INTO `__new_beenvoice_invoice`("id", "invoiceNumber", "businessId", "clientId", "issueDate", "dueDate", "status", "totalAmount", "taxRate", "notes", "createdById", "createdAt", "updatedAt") SELECT "id", "invoiceNumber", "businessId", "clientId", "issueDate", "dueDate", "status", "totalAmount", "taxRate", "notes", "createdById", "createdAt", "updatedAt" FROM `beenvoice_invoice`;--> statement-breakpoint
|
|
||||||
DROP TABLE `beenvoice_invoice`;--> statement-breakpoint
|
|
||||||
ALTER TABLE `__new_beenvoice_invoice` RENAME TO `beenvoice_invoice`;--> statement-breakpoint
|
|
||||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoice_business_id_idx` ON `beenvoice_invoice` (`businessId`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoice_client_id_idx` ON `beenvoice_invoice` (`clientId`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoice_created_by_idx` ON `beenvoice_invoice` (`createdById`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoice_number_idx` ON `beenvoice_invoice` (`invoiceNumber`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoice_status_idx` ON `beenvoice_invoice` (`status`);
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "role" varchar(20) DEFAULT 'user' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "beenvoice_user"
|
||||||
|
SET "role" = 'admin'
|
||||||
|
WHERE "id" = (
|
||||||
|
SELECT "id"
|
||||||
|
FROM "beenvoice_user"
|
||||||
|
ORDER BY "createdAt" ASC
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "beenvoice_platform_setting" (
|
||||||
|
"id" varchar(50) PRIMARY KEY DEFAULT 'global' NOT NULL,
|
||||||
|
"brandName" varchar(100) DEFAULT 'beenvoice' NOT NULL,
|
||||||
|
"brandTagline" varchar(255) DEFAULT 'Simple and efficient invoicing for freelancers and small businesses' NOT NULL,
|
||||||
|
"brandLogoText" varchar(100) DEFAULT 'beenvoice' NOT NULL,
|
||||||
|
"brandIcon" varchar(20) DEFAULT '$' NOT NULL,
|
||||||
|
"colorTheme" varchar(50) DEFAULT 'slate' NOT NULL,
|
||||||
|
"customColor" varchar(50),
|
||||||
|
"theme" varchar(20) DEFAULT 'system' NOT NULL,
|
||||||
|
"interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL,
|
||||||
|
"bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
|
||||||
|
"headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
|
||||||
|
"radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL,
|
||||||
|
"sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "beenvoice_platform_setting" (
|
||||||
|
"id",
|
||||||
|
"brandName",
|
||||||
|
"brandTagline",
|
||||||
|
"brandLogoText",
|
||||||
|
"brandIcon",
|
||||||
|
"colorTheme",
|
||||||
|
"customColor",
|
||||||
|
"theme",
|
||||||
|
"interfaceTheme",
|
||||||
|
"bodyFontPreference",
|
||||||
|
"headingFontPreference",
|
||||||
|
"radiusPreference",
|
||||||
|
"sidebarStyle"
|
||||||
|
) VALUES (
|
||||||
|
'global',
|
||||||
|
'beenvoice',
|
||||||
|
'Simple and efficient invoicing for freelancers and small businesses',
|
||||||
|
'beenvoice',
|
||||||
|
'$',
|
||||||
|
'slate',
|
||||||
|
NULL,
|
||||||
|
'system',
|
||||||
|
'beenvoice',
|
||||||
|
'brand',
|
||||||
|
'brand',
|
||||||
|
'xl',
|
||||||
|
'floating'
|
||||||
|
) ON CONFLICT ("id") DO NOTHING;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfTemplate" varchar(20) DEFAULT 'classic' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfAccentColor" varchar(50) DEFAULT '#111827' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfFooterText" varchar(120) DEFAULT 'Professional Invoicing' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfShowLogo" boolean DEFAULT true NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfShowPageNumbers" boolean DEFAULT true NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "beenvoice_invoice"
|
||||||
|
ADD COLUMN "emailMessage" varchar(2000);
|
||||||
+697
-322
File diff suppressed because it is too large
Load Diff
+1203
-268
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,61 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "postgresql",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "7",
|
||||||
"when": 1752275489999,
|
"when": 1775354242672,
|
||||||
"tag": "0000_unique_loa",
|
"tag": "0000_glossy_magneto",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775356013998,
|
||||||
|
"tag": "0001_supreme_the_enforcers",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775400000000,
|
||||||
|
"tag": "0002_tax_deductible",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775600000000,
|
||||||
|
"tag": "0003_appearance_preferences",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777336000000,
|
||||||
|
"tag": "0004_platform_appearance_controls",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777337000000,
|
||||||
|
"tag": "0005_platform_settings_and_roles",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777338000000,
|
||||||
|
"tag": "0006_pdf_generation_settings",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777339000000,
|
||||||
|
"tag": "0007_invoice_email_message",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+2
-6
@@ -1,17 +1,13 @@
|
|||||||
import { FlatCompat } from "@eslint/eslintrc";
|
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
// @ts-ignore -- no types for this plugin
|
// @ts-ignore -- no types for this plugin
|
||||||
import drizzle from "eslint-plugin-drizzle";
|
import drizzle from "eslint-plugin-drizzle";
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
|
||||||
baseDirectory: import.meta.dirname,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: [".next"],
|
ignores: [".next"],
|
||||||
},
|
},
|
||||||
...compat.extends("next/core-web-vitals"),
|
...nextCoreWebVitals,
|
||||||
{
|
{
|
||||||
files: ["**/*.ts", "**/*.tsx"],
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
+2
-3
@@ -6,9 +6,8 @@ import "./src/env.js";
|
|||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
eslint: {
|
output: "standalone",
|
||||||
ignoreDuringBuilds: true,
|
serverExternalPackages: ["pg"],
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
Generated
-8182
File diff suppressed because it is too large
Load Diff
+73
-64
@@ -5,105 +5,114 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"check": "next lint && tsc --noEmit",
|
"check": "eslint . && tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "bun drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:push-local-to-live": "node scripts/migrate-to-turso.js",
|
"db:clone": "./scripts/clone-local.sh",
|
||||||
"db:push-simple": "node scripts/migrate-simple.js",
|
"docker:up": "colima start && docker compose up -d",
|
||||||
"db:push-direct": "node scripts/migrate-direct.js",
|
"docker:dev:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
|
||||||
"db:export-data": "node scripts/export-data.js",
|
"docker:down": "docker compose down && colima stop",
|
||||||
"db:import-data": "node scripts/import-data-final.js",
|
"docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
|
||||||
"deploy": "drizzle-kit push && next build",
|
"deploy": "drizzle-kit push && next build",
|
||||||
"dev": "next dev --turbo",
|
"dev": "next dev --turbo",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"lint": "next lint",
|
"lint": "eslint .",
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "eslint --fix .",
|
||||||
"preview": "next build && next start",
|
"preview": "next build && next start",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/drizzle-adapter": "^1.7.2",
|
"@better-auth/sso": "^1.4.12",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@libsql/client": "^0.14.0",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.11",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.14",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@react-pdf/renderer": "^4.3.0",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@react-pdf/renderer": "^4.3.1",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
"@tanstack/react-query": "^5.69.0",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@trpc/client": "^11.0.0",
|
"@tiptap/extension-color": "^3.13.0",
|
||||||
"@trpc/react-query": "^11.0.0",
|
"@tiptap/extension-list-item": "^3.13.0",
|
||||||
"@trpc/server": "^11.0.0",
|
"@tiptap/extension-text-align": "^3.13.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@tiptap/extension-text-style": "^3.13.0",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@tiptap/react": "^3.13.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"@tiptap/starter-kit": "^3.13.0",
|
||||||
"chrono-node": "^2.8.3",
|
"@trpc/client": "^11.7.2",
|
||||||
|
"@trpc/react-query": "^11.7.2",
|
||||||
|
"@trpc/server": "^11.7.2",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-auth": "^1.4.12",
|
||||||
|
"chrono-node": "^2.9.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.41.0",
|
"dotenv": "^17.2.3",
|
||||||
|
"drizzle-orm": "^0.44.7",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"lucide": "^0.525.0",
|
"framer-motion": "^12.23.26",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "^15.4.1",
|
"next": "^16.2.2",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"pg": "8.13.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.2.4",
|
||||||
"react-day-picker": "^9.8.0",
|
"react-day-picker": "^9.12.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.2.4",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
|
"recharts": "^3.5.1",
|
||||||
|
"resend": "^4.8.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.7",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.4.0",
|
||||||
"turso": "^0.1.0",
|
"zod": "^3.25.76"
|
||||||
"zod": "^3.24.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tailwindcss/postcss": "^4.0.15",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.19.26",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/raf": "^3.4.3",
|
"@types/raf": "^3.4.3",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"better-sqlite3": "^12.2.0",
|
"baseline-browser-mapping": "^2.9.6",
|
||||||
"drizzle-kit": "^0.30.5",
|
"drizzle-kit": "^0.30.6",
|
||||||
"eslint": "^9.23.0",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-next": "^15.2.3",
|
"eslint-config-next": "^16.0.10",
|
||||||
"eslint-plugin-drizzle": "^0.2.3",
|
"eslint-plugin-drizzle": "^0.2.3",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
"tailwindcss": "^4.0.15",
|
"tailwindcss": "^4.1.18",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.27.0"
|
"typescript-eslint": "^8.49.0"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.39.3"
|
"initVersion": "7.39.3"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@tailwindcss/oxide",
|
"@tailwindcss/oxide",
|
||||||
"better-sqlite3",
|
|
||||||
"core-js",
|
"core-js",
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"sharp",
|
"sharp",
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
export default {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
"@tailwindcss/postcss": {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||||
export default {
|
const config = {
|
||||||
plugins: ["prettier-plugin-tailwindcss"],
|
plugins: ["prettier-plugin-tailwindcss"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 56 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 163 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+71
@@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Function to read a variable from a specific env file
|
||||||
|
read_env_var() {
|
||||||
|
local file="$1"
|
||||||
|
local var="$2"
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
grep "^$var=" "$file" | cut -d '=' -f2- | tr -d '"' | tr -d "'"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Get Production URL
|
||||||
|
# Priority: Argument > .env.production > .env
|
||||||
|
PROD_DB_URL="$1"
|
||||||
|
|
||||||
|
if [ -z "$PROD_DB_URL" ]; then
|
||||||
|
echo "Checking .env.production for DATABASE_URL..."
|
||||||
|
PROD_DB_URL=$(read_env_var ".env.production" "DATABASE_URL")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PROD_DB_URL" ]; then
|
||||||
|
echo "Checking .env for PROD_DATABASE_URL..."
|
||||||
|
PROD_DB_URL=$(read_env_var ".env" "PROD_DATABASE_URL")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PROD_DB_URL" ]; then
|
||||||
|
echo "Error: Could not find production database URL."
|
||||||
|
echo "Please provide it as an argument, or set DATABASE_URL in .env.production, or PROD_DATABASE_URL in .env"
|
||||||
|
echo "Usage: $0 <PROD_DATABASE_URL>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Get Target URL
|
||||||
|
# Priority: .env.local > .env
|
||||||
|
TARGET_DB_URL=$(read_env_var ".env.local" "DATABASE_URL")
|
||||||
|
if [ -z "$TARGET_DB_URL" ]; then TARGET_DB_URL=$(read_env_var ".env" "DATABASE_URL"); fi
|
||||||
|
|
||||||
|
if [ -z "$TARGET_DB_URL" ]; then
|
||||||
|
echo "Error: Could not find target DATABASE_URL in .env.local or .env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Configuration:"
|
||||||
|
echo " Source: $PROD_DB_URL"
|
||||||
|
echo " Target: $TARGET_DB_URL"
|
||||||
|
echo
|
||||||
|
echo "⚠️ WARNING: This will OVERWRITE the target database at the above URL."
|
||||||
|
echo "This is a one-time migration script."
|
||||||
|
read -p "Are you sure you want to continue? (y/N) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Cloning database..."
|
||||||
|
|
||||||
|
# Use local pg_dump and psql directly
|
||||||
|
# This assumes pg_dump and psql are installed on the host machine
|
||||||
|
pg_dump "$PROD_DB_URL" \
|
||||||
|
--clean --if-exists \
|
||||||
|
--no-owner --no-privileges \
|
||||||
|
--format=plain \
|
||||||
|
| psql "$TARGET_DB_URL"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Database cloned successfully!"
|
||||||
|
else
|
||||||
|
echo "❌ Database clone failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { execSync } from "child_process";
|
|
||||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
||||||
|
|
||||||
async function exportData() {
|
|
||||||
console.log("📦 Exporting data from local SQLite database...\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if local database exists
|
|
||||||
if (!existsSync("./db.sqlite")) {
|
|
||||||
console.error("❌ Local database db.sqlite not found!");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log("✅ Found local database");
|
|
||||||
|
|
||||||
// Create SQL dump
|
|
||||||
console.log("🔄 Creating SQL dump...");
|
|
||||||
const dumpPath = "./data_export.sql";
|
|
||||||
|
|
||||||
try {
|
|
||||||
execSync(`sqlite3 db.sqlite ".dump" > ${dumpPath}`, { stdio: "inherit" });
|
|
||||||
console.log("✅ SQL dump created");
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"❌ Failed to create SQL dump. Make sure sqlite3 is installed.",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and filter the dump file
|
|
||||||
console.log("🔍 Extracting data statements...");
|
|
||||||
const dumpContent = readFileSync(dumpPath, "utf8");
|
|
||||||
const lines = dumpContent.split("\n");
|
|
||||||
|
|
||||||
// Extract only INSERT statements for beenvoice tables
|
|
||||||
const dataStatements = [];
|
|
||||||
|
|
||||||
// Add header comment
|
|
||||||
dataStatements.push("-- beenvoice Data Export");
|
|
||||||
dataStatements.push("-- Generated: " + new Date().toISOString());
|
|
||||||
dataStatements.push(
|
|
||||||
"-- Run these INSERT statements in your Turso database",
|
|
||||||
);
|
|
||||||
dataStatements.push("");
|
|
||||||
|
|
||||||
// Extract table data in proper order (for foreign keys)
|
|
||||||
const tableOrder = [
|
|
||||||
"beenvoice_user",
|
|
||||||
"beenvoice_account",
|
|
||||||
"beenvoice_session",
|
|
||||||
"beenvoice_client",
|
|
||||||
"beenvoice_business",
|
|
||||||
"beenvoice_invoice",
|
|
||||||
"beenvoice_invoice_item",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const tableName of tableOrder) {
|
|
||||||
const tableStatements = lines.filter(
|
|
||||||
(line) =>
|
|
||||||
line.startsWith(`INSERT INTO ${tableName}`) ||
|
|
||||||
line.startsWith(`INSERT INTO \`${tableName}\``),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tableStatements.length > 0) {
|
|
||||||
dataStatements.push(
|
|
||||||
`-- Data for ${tableName} (${tableStatements.length} records)`,
|
|
||||||
);
|
|
||||||
dataStatements.push(...tableStatements);
|
|
||||||
dataStatements.push("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write clean export file
|
|
||||||
const exportContent = dataStatements.join("\n");
|
|
||||||
writeFileSync("./beenvoice_data_export.sql", exportContent);
|
|
||||||
|
|
||||||
// Count total records
|
|
||||||
const totalInserts = dataStatements.filter((line) =>
|
|
||||||
line.startsWith("INSERT"),
|
|
||||||
).length;
|
|
||||||
|
|
||||||
console.log(`\n🎉 Data export completed!`);
|
|
||||||
console.log(` 📄 File: beenvoice_data_export.sql`);
|
|
||||||
console.log(` 📊 Total records: ${totalInserts}`);
|
|
||||||
console.log(`\n📋 Manual steps to complete migration:`);
|
|
||||||
console.log(` 1. Run: bun run db:push (to create tables in Turso)`);
|
|
||||||
console.log(
|
|
||||||
` 2. Copy the INSERT statements from beenvoice_data_export.sql`,
|
|
||||||
);
|
|
||||||
console.log(` 3. Run them in your Turso database`);
|
|
||||||
console.log(
|
|
||||||
`\n💡 Or use turso db shell beenvoice < beenvoice_data_export.sql`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up temp file
|
|
||||||
try {
|
|
||||||
execSync(`rm ${dumpPath}`);
|
|
||||||
} catch (e) {
|
|
||||||
// Cleanup failed, that's okay
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"\n❌ Export failed:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exportData().catch(console.error);
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { createClient } from "@libsql/client";
|
|
||||||
import { readFileSync, existsSync } from "fs";
|
|
||||||
|
|
||||||
// Read .env file directly
|
|
||||||
function loadEnvVars() {
|
|
||||||
const envPath = "./.env";
|
|
||||||
if (!existsSync(envPath)) {
|
|
||||||
console.error("❌ .env file not found!");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const envContent = readFileSync(envPath, "utf8");
|
|
||||||
const envVars = /** @type {Record<string, string>} */ ({});
|
|
||||||
|
|
||||||
envContent.split("\n").forEach((line) => {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
|
|
||||||
const [key, ...valueParts] = trimmed.split("=");
|
|
||||||
if (key) {
|
|
||||||
const value = valueParts.join("=").replace(/^["']|["']$/g, "");
|
|
||||||
envVars[key.trim()] = value.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return envVars;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importData() {
|
|
||||||
console.log("🚀 Importing data to live Turso database...\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Load environment variables
|
|
||||||
console.log("🔧 Loading environment variables...");
|
|
||||||
const envVars = loadEnvVars();
|
|
||||||
|
|
||||||
if (!envVars.DATABASE_URL || !envVars.DATABASE_AUTH_TOKEN) {
|
|
||||||
console.error(
|
|
||||||
"❌ Missing DATABASE_URL or DATABASE_AUTH_TOKEN in .env file",
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"💡 Make sure your .env file contains your Turso credentials",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log("✅ Environment variables loaded");
|
|
||||||
|
|
||||||
// Check if export file exists
|
|
||||||
const exportFile = "./beenvoice_data_export.sql";
|
|
||||||
if (!existsSync(exportFile)) {
|
|
||||||
console.error("❌ Export file not found!");
|
|
||||||
console.log(
|
|
||||||
"💡 Run 'bun run db:export-data' first to create the export file",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log("✅ Found data export file");
|
|
||||||
|
|
||||||
// Connect to Turso
|
|
||||||
console.log("🔗 Connecting to live Turso database...");
|
|
||||||
const tursoClient = createClient({
|
|
||||||
url: envVars.DATABASE_URL,
|
|
||||||
authToken: envVars.DATABASE_AUTH_TOKEN,
|
|
||||||
});
|
|
||||||
console.log("✅ Connected to Turso");
|
|
||||||
|
|
||||||
// Read the export file
|
|
||||||
console.log("📖 Reading export file...");
|
|
||||||
const sqlContent = readFileSync(exportFile, "utf8");
|
|
||||||
const lines = sqlContent.split("\n");
|
|
||||||
|
|
||||||
// Filter for INSERT statements only
|
|
||||||
const insertStatements = lines.filter((line) =>
|
|
||||||
line.trim().startsWith("INSERT INTO beenvoice_"),
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`📊 Found ${insertStatements.length} data records to import`);
|
|
||||||
|
|
||||||
if (insertStatements.length === 0) {
|
|
||||||
console.log("⚠️ No INSERT statements found in export file");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing data first (in reverse foreign key order)
|
|
||||||
console.log("🗑️ Clearing existing data...");
|
|
||||||
const tablesToClear = [
|
|
||||||
"beenvoice_invoice_item",
|
|
||||||
"beenvoice_invoice",
|
|
||||||
"beenvoice_business",
|
|
||||||
"beenvoice_client",
|
|
||||||
"beenvoice_session",
|
|
||||||
"beenvoice_account",
|
|
||||||
"beenvoice_user",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const table of tablesToClear) {
|
|
||||||
try {
|
|
||||||
await tursoClient.execute(`DELETE FROM ${table}`);
|
|
||||||
console.log(` ✅ Cleared ${table}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(
|
|
||||||
` ⏭️ Skipped ${table} (${error instanceof Error ? error.message : String(error)})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute INSERT statements
|
|
||||||
console.log("📤 Importing data...");
|
|
||||||
let successCount = 0;
|
|
||||||
let errorCount = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < insertStatements.length; i++) {
|
|
||||||
const statementLine = insertStatements[i];
|
|
||||||
if (!statementLine) continue;
|
|
||||||
|
|
||||||
const statement = statementLine.trim();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await tursoClient.execute(statement);
|
|
||||||
successCount++;
|
|
||||||
|
|
||||||
// Show progress every 50 records
|
|
||||||
if (successCount % 50 === 0) {
|
|
||||||
console.log(
|
|
||||||
` 📝 Imported ${successCount}/${insertStatements.length} records...`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
errorCount++;
|
|
||||||
if (errorCount <= 5) {
|
|
||||||
// Only show first 5 errors
|
|
||||||
console.error(
|
|
||||||
` ❌ Error importing record ${i + 1}: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the import
|
|
||||||
console.log("\n🔍 Verifying import...");
|
|
||||||
const tables = [
|
|
||||||
"beenvoice_user",
|
|
||||||
"beenvoice_client",
|
|
||||||
"beenvoice_business",
|
|
||||||
"beenvoice_invoice",
|
|
||||||
"beenvoice_invoice_item",
|
|
||||||
];
|
|
||||||
|
|
||||||
let totalRecords = 0;
|
|
||||||
for (const table of tables) {
|
|
||||||
try {
|
|
||||||
const result = await tursoClient.execute(
|
|
||||||
`SELECT COUNT(*) as count FROM ${table}`,
|
|
||||||
);
|
|
||||||
const count = parseInt(String(result.rows[0]?.count || 0));
|
|
||||||
if (count > 0) {
|
|
||||||
console.log(` 📊 ${table}: ${count} records`);
|
|
||||||
totalRecords += count;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(` ⏭️ ${table}: not accessible`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n🎉 Import completed!`);
|
|
||||||
console.log(` ✅ ${successCount} records imported successfully`);
|
|
||||||
if (errorCount > 0) {
|
|
||||||
console.log(` ⚠️ ${errorCount} records had errors`);
|
|
||||||
}
|
|
||||||
console.log(` 📊 ${totalRecords} total records now in live database`);
|
|
||||||
console.log(`\n💡 Your local data is now live on Turso!`);
|
|
||||||
console.log(`💡 Your Vercel deployment will use this data.`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"\n❌ Import failed:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
console.log("🔌 Done!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
importData().catch(console.error);
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
import { createClient } from "@libsql/client";
|
|
||||||
import { execSync } from "child_process";
|
|
||||||
import { readFileSync, writeFileSync, unlinkSync, existsSync } from "fs";
|
|
||||||
|
|
||||||
// Read .env file directly
|
|
||||||
function loadEnvVars() {
|
|
||||||
const envPath = "./.env";
|
|
||||||
if (!existsSync(envPath)) {
|
|
||||||
console.error("❌ .env file not found!");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const envContent = readFileSync(envPath, "utf8");
|
|
||||||
const envVars = /** @type {Record<string, string>} */ ({});
|
|
||||||
|
|
||||||
envContent.split("\n").forEach((line) => {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
|
|
||||||
const [key, ...valueParts] = trimmed.split("=");
|
|
||||||
if (key) {
|
|
||||||
const value = valueParts.join("=").replace(/^["']|["']$/g, ""); // Remove quotes
|
|
||||||
envVars[key.trim()] = value.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return envVars;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateToTurso() {
|
|
||||||
console.log("🚀 Pushing local SQLite data to live Turso database...\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Load environment variables
|
|
||||||
console.log("🔧 Loading environment variables...");
|
|
||||||
const envVars = loadEnvVars();
|
|
||||||
|
|
||||||
if (!envVars.DATABASE_URL || !envVars.DATABASE_AUTH_TOKEN) {
|
|
||||||
console.error(
|
|
||||||
"❌ Missing DATABASE_URL or DATABASE_AUTH_TOKEN in .env file",
|
|
||||||
);
|
|
||||||
console.log("💡 Make sure your .env file contains:");
|
|
||||||
console.log(" DATABASE_URL=libsql://your-database-url");
|
|
||||||
console.log(" DATABASE_AUTH_TOKEN=your-auth-token");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log("✅ Environment variables loaded");
|
|
||||||
|
|
||||||
// Check if local database exists
|
|
||||||
console.log("📁 Checking local database...");
|
|
||||||
if (!existsSync("./db.sqlite")) {
|
|
||||||
console.error("❌ Local database db.sqlite not found!");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log("✅ Found local database");
|
|
||||||
|
|
||||||
// Create SQL dump of local database
|
|
||||||
console.log("📦 Creating SQL dump from local database...");
|
|
||||||
const dumpPath = "./temp_dump.sql";
|
|
||||||
|
|
||||||
try {
|
|
||||||
execSync(`sqlite3 db.sqlite ".dump" > ${dumpPath}`, { stdio: "inherit" });
|
|
||||||
console.log("✅ SQL dump created");
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"❌ Failed to create SQL dump. Make sure sqlite3 is installed.",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and filter the dump file
|
|
||||||
console.log("🔍 Processing SQL dump...");
|
|
||||||
const dumpContent = readFileSync(dumpPath, "utf8");
|
|
||||||
|
|
||||||
// Split into lines and filter for beenvoice tables
|
|
||||||
const lines = dumpContent.split("\n");
|
|
||||||
const filteredLines = [];
|
|
||||||
let inBeenvoiceTable = false;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
// Skip PRAGMA and TRANSACTION statements
|
|
||||||
if (
|
|
||||||
line.startsWith("PRAGMA") ||
|
|
||||||
line.startsWith("BEGIN TRANSACTION") ||
|
|
||||||
line.startsWith("COMMIT")
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're starting a beenvoice table
|
|
||||||
if (
|
|
||||||
line.startsWith("CREATE TABLE `beenvoice_") ||
|
|
||||||
line.startsWith("CREATE TABLE beenvoice_")
|
|
||||||
) {
|
|
||||||
inBeenvoiceTable = true;
|
|
||||||
filteredLines.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're inserting into a beenvoice table
|
|
||||||
if (
|
|
||||||
line.startsWith("INSERT INTO beenvoice_") ||
|
|
||||||
line.startsWith("INSERT INTO `beenvoice_")
|
|
||||||
) {
|
|
||||||
filteredLines.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we were in a beenvoice table and hit another CREATE TABLE, we're done with that table
|
|
||||||
if (
|
|
||||||
inBeenvoiceTable &&
|
|
||||||
line.startsWith("CREATE TABLE") &&
|
|
||||||
!line.includes("beenvoice_")
|
|
||||||
) {
|
|
||||||
inBeenvoiceTable = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're in a beenvoice table, include the line
|
|
||||||
if (inBeenvoiceTable) {
|
|
||||||
filteredLines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ Filtered ${filteredLines.length} SQL statements`);
|
|
||||||
|
|
||||||
// Connect to Turso
|
|
||||||
console.log("🔗 Connecting to live Turso database...");
|
|
||||||
const tursoClient = createClient({
|
|
||||||
url: envVars.DATABASE_URL,
|
|
||||||
authToken: envVars.DATABASE_AUTH_TOKEN,
|
|
||||||
});
|
|
||||||
console.log("✅ Connected to Turso");
|
|
||||||
|
|
||||||
// Clear existing data from beenvoice tables (in reverse order for foreign keys)
|
|
||||||
console.log("🗑️ Clearing existing data...");
|
|
||||||
const tablesToClear = [
|
|
||||||
"beenvoice_invoice_item",
|
|
||||||
"beenvoice_invoice",
|
|
||||||
"beenvoice_client",
|
|
||||||
"beenvoice_business",
|
|
||||||
"beenvoice_session",
|
|
||||||
"beenvoice_account",
|
|
||||||
"beenvoice_user",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const table of tablesToClear) {
|
|
||||||
try {
|
|
||||||
await tursoClient.execute(`DELETE FROM ${table}`);
|
|
||||||
console.log(` ✅ Cleared ${table}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(
|
|
||||||
` ⏭️ Skipped ${table} (doesn't exist or error: ${error instanceof Error ? error.message : String(error)})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the filtered SQL statements
|
|
||||||
console.log("📤 Pushing data to Turso...");
|
|
||||||
let successCount = 0;
|
|
||||||
let errorCount = 0;
|
|
||||||
let insertCount = 0;
|
|
||||||
|
|
||||||
for (const line of filteredLines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed === "") continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await tursoClient.execute(trimmed);
|
|
||||||
successCount++;
|
|
||||||
|
|
||||||
// Count and show progress for inserts
|
|
||||||
if (trimmed.startsWith("INSERT")) {
|
|
||||||
insertCount++;
|
|
||||||
if (insertCount % 20 === 0) {
|
|
||||||
console.log(` 📝 Inserted ${insertCount} records...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
errorCount++;
|
|
||||||
if (trimmed.startsWith("CREATE TABLE")) {
|
|
||||||
console.log(
|
|
||||||
` ⚠️ Table already exists: ${trimmed.substring(0, 50)}...`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
` ❌ Error executing: ${trimmed.substring(0, 50)}...`,
|
|
||||||
);
|
|
||||||
console.error(
|
|
||||||
` Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the migration
|
|
||||||
console.log("\n🔍 Verifying migration...");
|
|
||||||
const tables = [
|
|
||||||
"beenvoice_user",
|
|
||||||
"beenvoice_client",
|
|
||||||
"beenvoice_business",
|
|
||||||
"beenvoice_invoice",
|
|
||||||
"beenvoice_invoice_item",
|
|
||||||
];
|
|
||||||
|
|
||||||
let totalRecords = 0;
|
|
||||||
for (const table of tables) {
|
|
||||||
try {
|
|
||||||
const result = await tursoClient.execute(
|
|
||||||
`SELECT COUNT(*) as count FROM ${table}`,
|
|
||||||
);
|
|
||||||
const count = String(result.rows[0]?.count || 0);
|
|
||||||
console.log(` 📊 ${table}: ${count} records`);
|
|
||||||
totalRecords += parseInt(count);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(` ⏭️ ${table}: table doesn't exist`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n🎉 Migration completed successfully!`);
|
|
||||||
console.log(` ✅ ${successCount} SQL statements executed`);
|
|
||||||
console.log(` 📝 ${insertCount} data records inserted`);
|
|
||||||
console.log(` 📊 ${totalRecords} total records in live database`);
|
|
||||||
if (errorCount > 0) {
|
|
||||||
console.log(
|
|
||||||
` ⚠️ ${errorCount} statements had errors (likely table creation conflicts)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(`\n💡 Your local data is now live on Turso!`);
|
|
||||||
console.log(`💡 Your Vercel deployment will use this data.`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"\n❌ Migration failed:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
console.error("Full error:", error);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
// Cleanup
|
|
||||||
try {
|
|
||||||
if (existsSync("./temp_dump.sql")) {
|
|
||||||
unlinkSync("./temp_dump.sql");
|
|
||||||
console.log("🧹 Cleaned up temporary files");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// File cleanup failed, that's okay
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("🔌 Done!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
migrateToTurso().catch(console.error);
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { createClient } from "@libsql/client";
|
|
||||||
import { execSync } from "child_process";
|
|
||||||
import { readFileSync, writeFileSync, unlinkSync, existsSync } from "fs";
|
|
||||||
import { env } from "../src/env.js";
|
|
||||||
|
|
||||||
async function migrateToTurso() {
|
|
||||||
console.log("🚀 Pushing local SQLite data to live Turso database...\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if local database exists
|
|
||||||
console.log("📁 Checking local database...");
|
|
||||||
const dbExists = existsSync("./db.sqlite");
|
|
||||||
if (!dbExists) {
|
|
||||||
console.error("❌ Local database db.sqlite not found!");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log("✅ Found local database");
|
|
||||||
|
|
||||||
// Create SQL dump of local database
|
|
||||||
console.log("📦 Creating SQL dump from local database...");
|
|
||||||
const dumpPath = "./temp_dump.sql";
|
|
||||||
|
|
||||||
try {
|
|
||||||
execSync(`sqlite3 db.sqlite ".dump" > ${dumpPath}`, { stdio: "inherit" });
|
|
||||||
console.log("✅ SQL dump created");
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"❌ Failed to create SQL dump. Make sure sqlite3 is installed.",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and filter the dump file
|
|
||||||
console.log("🔍 Processing SQL dump...");
|
|
||||||
const dumpContent = readFileSync(dumpPath, "utf8");
|
|
||||||
|
|
||||||
// Split into lines and filter for beenvoice tables
|
|
||||||
const lines = dumpContent.split("\n");
|
|
||||||
const filteredLines = [];
|
|
||||||
let inBeenvoiceTable = false;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
// Skip PRAGMA and TRANSACTION statements
|
|
||||||
if (
|
|
||||||
line.startsWith("PRAGMA") ||
|
|
||||||
line.startsWith("BEGIN TRANSACTION") ||
|
|
||||||
line.startsWith("COMMIT")
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're starting a beenvoice table
|
|
||||||
if (
|
|
||||||
line.startsWith("CREATE TABLE `beenvoice_") ||
|
|
||||||
line.startsWith("CREATE TABLE beenvoice_")
|
|
||||||
) {
|
|
||||||
inBeenvoiceTable = true;
|
|
||||||
filteredLines.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're inserting into a beenvoice table
|
|
||||||
if (
|
|
||||||
line.startsWith("INSERT INTO beenvoice_") ||
|
|
||||||
line.startsWith("INSERT INTO `beenvoice_")
|
|
||||||
) {
|
|
||||||
filteredLines.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we were in a beenvoice table and hit another CREATE TABLE, we're done with that table
|
|
||||||
if (
|
|
||||||
inBeenvoiceTable &&
|
|
||||||
line.startsWith("CREATE TABLE") &&
|
|
||||||
!line.includes("beenvoice_")
|
|
||||||
) {
|
|
||||||
inBeenvoiceTable = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're in a beenvoice table, include the line
|
|
||||||
if (inBeenvoiceTable) {
|
|
||||||
filteredLines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ Filtered ${filteredLines.length} SQL statements`);
|
|
||||||
|
|
||||||
// Connect to Turso
|
|
||||||
console.log("🔗 Connecting to live Turso database...");
|
|
||||||
|
|
||||||
if (!env.DATABASE_URL || !env.DATABASE_AUTH_TOKEN) {
|
|
||||||
console.error("❌ Missing DATABASE_URL or DATABASE_AUTH_TOKEN");
|
|
||||||
console.log("💡 Make sure your .env file has the Turso credentials");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tursoClient = createClient({
|
|
||||||
url: env.DATABASE_URL,
|
|
||||||
authToken: env.DATABASE_AUTH_TOKEN,
|
|
||||||
});
|
|
||||||
console.log("✅ Connected to Turso");
|
|
||||||
|
|
||||||
// Clear existing data from beenvoice tables
|
|
||||||
console.log("🗑️ Clearing existing data...");
|
|
||||||
const tablesToClear = [
|
|
||||||
"beenvoice_invoice_item",
|
|
||||||
"beenvoice_invoice",
|
|
||||||
"beenvoice_client",
|
|
||||||
"beenvoice_business",
|
|
||||||
"beenvoice_session",
|
|
||||||
"beenvoice_account",
|
|
||||||
"beenvoice_user",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const table of tablesToClear) {
|
|
||||||
try {
|
|
||||||
await tursoClient.execute(`DELETE FROM ${table}`);
|
|
||||||
console.log(` ✅ Cleared ${table}`);
|
|
||||||
} catch (error) {
|
|
||||||
// Table might not exist, that's okay
|
|
||||||
console.log(` ⏭️ Skipped ${table} (doesn't exist)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the filtered SQL statements
|
|
||||||
console.log("📤 Pushing data to Turso...");
|
|
||||||
let successCount = 0;
|
|
||||||
let errorCount = 0;
|
|
||||||
|
|
||||||
for (const line of filteredLines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed === "") continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await tursoClient.execute(trimmed);
|
|
||||||
successCount++;
|
|
||||||
|
|
||||||
// Show progress for inserts
|
|
||||||
if (trimmed.startsWith("INSERT")) {
|
|
||||||
const match = trimmed.match(/INSERT INTO (\w+)/);
|
|
||||||
if (match && successCount % 10 === 0) {
|
|
||||||
console.log(` 📝 Inserted ${successCount} records...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
errorCount++;
|
|
||||||
if (trimmed.startsWith("CREATE TABLE")) {
|
|
||||||
console.log(
|
|
||||||
` ⚠️ Table already exists: ${trimmed.substring(0, 50)}...`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
` ❌ Error executing: ${trimmed.substring(0, 50)}...`,
|
|
||||||
);
|
|
||||||
console.error(
|
|
||||||
` Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the migration
|
|
||||||
console.log("\n🔍 Verifying migration...");
|
|
||||||
const tables = [
|
|
||||||
"beenvoice_user",
|
|
||||||
"beenvoice_client",
|
|
||||||
"beenvoice_invoice",
|
|
||||||
"beenvoice_invoice_item",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const table of tables) {
|
|
||||||
try {
|
|
||||||
const result = await tursoClient.execute(
|
|
||||||
`SELECT COUNT(*) as count FROM ${table}`,
|
|
||||||
);
|
|
||||||
const count = result.rows[0]?.count || 0;
|
|
||||||
console.log(` 📊 ${table}: ${count} records`);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(` ⏭️ ${table}: table doesn't exist`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n🎉 Migration completed!`);
|
|
||||||
console.log(` ✅ ${successCount} statements executed successfully`);
|
|
||||||
if (errorCount > 0) {
|
|
||||||
console.log(
|
|
||||||
` ⚠️ ${errorCount} statements had errors (likely table creation conflicts)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(`\n💡 Your local data is now live on Turso!`);
|
|
||||||
console.log(`💡 Your Vercel deployment will use this data.`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"\n❌ Migration failed:",
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
// Cleanup
|
|
||||||
try {
|
|
||||||
unlinkSync("./temp_dump.sql");
|
|
||||||
console.log("🧹 Cleaned up temporary files");
|
|
||||||
} catch (e) {
|
|
||||||
// File might not exist, that's okay
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("🔌 Done!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
migrateToTurso().catch(console.error);
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { createClient } from "@libsql/client";
|
|
||||||
import Database from "better-sqlite3";
|
|
||||||
import { env } from "../src/env.js";
|
|
||||||
|
|
||||||
async function migrateToTurso() {
|
|
||||||
console.log("🚀 Pushing local data to live Turso database...\n");
|
|
||||||
|
|
||||||
// Connect to local SQLite database
|
|
||||||
const localDb = new Database("./db.sqlite");
|
|
||||||
console.log("✅ Connected to local database");
|
|
||||||
|
|
||||||
// Connect to live Turso database using existing env vars
|
|
||||||
const tursoClient = createClient({
|
|
||||||
url: env.DATABASE_URL,
|
|
||||||
authToken: env.DATABASE_AUTH_TOKEN,
|
|
||||||
});
|
|
||||||
console.log("✅ Connected to live Turso database");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get all tables with data
|
|
||||||
const tables = localDb
|
|
||||||
.prepare(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'beenvoice_%'",
|
|
||||||
)
|
|
||||||
.all();
|
|
||||||
|
|
||||||
console.log(`\n📋 Found ${tables.length} tables to migrate:`);
|
|
||||||
tables.forEach((table) => console.log(` - ${table.name}`));
|
|
||||||
|
|
||||||
// Migration order to handle foreign key constraints
|
|
||||||
const migrationOrder = [
|
|
||||||
"beenvoice_user",
|
|
||||||
"beenvoice_account",
|
|
||||||
"beenvoice_session",
|
|
||||||
"beenvoice_client",
|
|
||||||
"beenvoice_business",
|
|
||||||
"beenvoice_invoice",
|
|
||||||
"beenvoice_invoice_item",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const tableName of migrationOrder) {
|
|
||||||
if (!tables.find((t) => t.name === tableName)) {
|
|
||||||
console.log(`⏭️ Skipping ${tableName} (not found locally)`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n📦 Processing ${tableName}...`);
|
|
||||||
|
|
||||||
// Get local data
|
|
||||||
const localData = localDb.prepare(`SELECT * FROM ${tableName}`).all();
|
|
||||||
console.log(` Found ${localData.length} local records`);
|
|
||||||
|
|
||||||
if (localData.length === 0) {
|
|
||||||
console.log(` ✅ No data to migrate`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear remote table first
|
|
||||||
await tursoClient.execute(`DELETE FROM ${tableName}`);
|
|
||||||
console.log(` 🗑️ Cleared remote table`);
|
|
||||||
|
|
||||||
// Insert all local data
|
|
||||||
for (const row of localData) {
|
|
||||||
const columns = Object.keys(row);
|
|
||||||
const values = Object.values(row);
|
|
||||||
const placeholders = columns.map(() => "?").join(", ");
|
|
||||||
|
|
||||||
const sql = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
|
|
||||||
|
|
||||||
await tursoClient.execute({
|
|
||||||
sql,
|
|
||||||
args: values,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(` ✅ Pushed ${localData.length} records to live database`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\n🎉 Migration completed!");
|
|
||||||
console.log("💡 Local data is now live on Turso");
|
|
||||||
console.log("💡 Your Vercel deployment will use this data");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("\n❌ Migration failed:", error.message);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
localDb.close();
|
|
||||||
tursoClient.close();
|
|
||||||
console.log("\n🔌 Connections closed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
migrateToTurso().catch(console.error);
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." &>/dev/null && pwd)"
|
||||||
|
cd "${PROJECT_ROOT}"
|
||||||
|
|
||||||
|
echo "[setup-env] Project root: ${PROJECT_ROOT}"
|
||||||
|
|
||||||
|
ENV_EXAMPLE_FILE="${PROJECT_ROOT}/env.example"
|
||||||
|
ENV_FILE="${PROJECT_ROOT}/.env"
|
||||||
|
|
||||||
|
FORCE=${FORCE:-false}
|
||||||
|
|
||||||
|
if [[ ! -f "${ENV_EXAMPLE_FILE}" ]]; then
|
||||||
|
echo "[setup-env] ERROR: env.example not found at ${ENV_EXAMPLE_FILE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "${ENV_FILE}" && "${FORCE}" != "true" ]]; then
|
||||||
|
echo "[setup-env] .env already exists. Set FORCE=true to overwrite. Skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[setup-env] Generating secrets for .env"
|
||||||
|
|
||||||
|
GEN_AUTH_SECRET=$(openssl rand -hex 32 2>/dev/null || cat /proc/sys/kernel/random/uuid)
|
||||||
|
GEN_DB_PASSWORD=$(openssl rand -hex 16 2>/dev/null || cat /proc/sys/kernel/random/uuid)
|
||||||
|
|
||||||
|
TMP_FILE=$(mktemp)
|
||||||
|
|
||||||
|
sed \
|
||||||
|
-e "s/^AUTH_SECRET=__GENERATE__/AUTH_SECRET=${GEN_AUTH_SECRET}/" \
|
||||||
|
-e "s/^POSTGRES_PASSWORD=__GENERATE__/POSTGRES_PASSWORD=${GEN_DB_PASSWORD}/" \
|
||||||
|
"${ENV_EXAMPLE_FILE}" > "${TMP_FILE}"
|
||||||
|
|
||||||
|
mv "${TMP_FILE}" "${ENV_FILE}"
|
||||||
|
|
||||||
|
echo "[setup-env] Wrote ${ENV_FILE} with generated AUTH_SECRET and POSTGRES_PASSWORD"
|
||||||
|
echo "[setup-env] You can edit ${ENV_FILE} to adjust PORT, RESEND_* and other values."
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Resolve project root (directory containing this script's parent)
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." &>/dev/null && pwd)"
|
||||||
|
cd "${PROJECT_ROOT}"
|
||||||
|
|
||||||
|
echo "[setup-env] Project root: ${PROJECT_ROOT}"
|
||||||
|
|
||||||
|
ENV_EXAMPLE_FILE="${PROJECT_ROOT}/env.example"
|
||||||
|
ENV_FILE="${PROJECT_ROOT}/.env"
|
||||||
|
|
||||||
|
FORCE=${FORCE:-false}
|
||||||
|
|
||||||
|
if [[ ! -f "${ENV_EXAMPLE_FILE}" ]]; then
|
||||||
|
echo "[setup-env] ERROR: env.example not found at ${ENV_EXAMPLE_FILE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "${ENV_FILE}" && "${FORCE}" != "true" ]]; then
|
||||||
|
echo "[setup-env] .env already exists. Set FORCE=true to overwrite. Skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[setup-env] Generating secrets for .env"
|
||||||
|
|
||||||
|
# Generate secrets
|
||||||
|
GEN_AUTH_SECRET=$(openssl rand -hex 32 2>/dev/null || cat /proc/sys/kernel/random/uuid)
|
||||||
|
GEN_DB_PASSWORD=$(openssl rand -hex 16 2>/dev/null || cat /proc/sys/kernel/random/uuid)
|
||||||
|
|
||||||
|
TMP_FILE=$(mktemp)
|
||||||
|
|
||||||
|
# Perform replacements
|
||||||
|
sed \
|
||||||
|
-e "s/^AUTH_SECRET=__GENERATE__/AUTH_SECRET=${GEN_AUTH_SECRET}/" \
|
||||||
|
-e "s/^POSTGRES_PASSWORD=__GENERATE__/POSTGRES_PASSWORD=${GEN_DB_PASSWORD}/" \
|
||||||
|
"${ENV_EXAMPLE_FILE}" > "${TMP_FILE}"
|
||||||
|
|
||||||
|
mv "${TMP_FILE}" "${ENV_FILE}"
|
||||||
|
|
||||||
|
echo "[setup-env] Wrote ${ENV_FILE} with generated AUTH_SECRET and POSTGRES_PASSWORD"
|
||||||
|
echo "[setup-env] You can edit ${ENV_FILE} to adjust PORT, RESEND_* and other values."
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export default function PrivacyPolicyPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-background min-h-screen">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-card border-b">
|
||||||
|
<div className="container mx-auto max-w-4xl px-6 py-6">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Link href="/auth/signin">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Privacy Policy</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Last updated: {new Date().toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="container mx-auto max-w-4xl px-6 py-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Introduction</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
beenvoice ("we", "our", or "us")
|
||||||
|
is committed to protecting your privacy. This Privacy Policy
|
||||||
|
explains how we collect, use, disclose, and safeguard your
|
||||||
|
information when you use our invoicing platform and services.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Please read this Privacy Policy carefully. If you do not agree
|
||||||
|
with the terms of this Privacy Policy, please do not access or
|
||||||
|
use our Service.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Information We Collect</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<h4>Personal Information</h4>
|
||||||
|
<p>
|
||||||
|
We may collect personal information that you voluntarily provide
|
||||||
|
to us when you:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Register for an account</li>
|
||||||
|
<li>Create invoices or manage client information</li>
|
||||||
|
<li>Contact us for support</li>
|
||||||
|
<li>Subscribe to our newsletters or communications</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>This personal information may include:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Name and contact information (email, phone, address)</li>
|
||||||
|
<li>Business information and tax details</li>
|
||||||
|
<li>Client information you input into the system</li>
|
||||||
|
<li>Financial information related to your invoices</li>
|
||||||
|
<li>
|
||||||
|
Payment information (processed securely by third-party
|
||||||
|
providers)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Automatically Collected Information</h4>
|
||||||
|
<p>
|
||||||
|
We may automatically collect certain information when you visit
|
||||||
|
our Service:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Device information (IP address, browser type, operating
|
||||||
|
system)
|
||||||
|
</li>
|
||||||
|
<li>Usage data (pages visited, time spent, features used)</li>
|
||||||
|
<li>Log files and analytics data</li>
|
||||||
|
<li>Cookies and similar tracking technologies</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>How We Use Your Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>We use the information we collect to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Provide, operate, and maintain our Service</li>
|
||||||
|
<li>Process your transactions and manage your account</li>
|
||||||
|
<li>Improve and personalize your experience</li>
|
||||||
|
<li>
|
||||||
|
Communicate with you about your account and our services
|
||||||
|
</li>
|
||||||
|
<li>Send you technical notices and support messages</li>
|
||||||
|
<li>Respond to your comments, questions, and requests</li>
|
||||||
|
<li>Monitor usage and analyze trends</li>
|
||||||
|
<li>
|
||||||
|
Detect, prevent, and address technical issues and security
|
||||||
|
breaches
|
||||||
|
</li>
|
||||||
|
<li>Comply with legal obligations</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>How We Share Your Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We do not sell, trade, or rent your personal information to
|
||||||
|
third parties. We may share your information in the following
|
||||||
|
circumstances:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>Service Providers</h4>
|
||||||
|
<p>
|
||||||
|
We may share your information with trusted third-party service
|
||||||
|
providers who assist us in operating our Service, such as:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Cloud hosting and storage providers</li>
|
||||||
|
<li>Payment processors</li>
|
||||||
|
<li>Email service providers</li>
|
||||||
|
<li>Analytics and monitoring services</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Legal Requirements</h4>
|
||||||
|
<p>
|
||||||
|
We may disclose your information if required to do so by law or
|
||||||
|
in response to:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Legal processes (subpoenas, court orders)</li>
|
||||||
|
<li>Government requests</li>
|
||||||
|
<li>Law enforcement investigations</li>
|
||||||
|
<li>Protection of our rights, property, or safety</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Business Transfers</h4>
|
||||||
|
<p>
|
||||||
|
In the event of a merger, acquisition, or sale of assets, your
|
||||||
|
information may be transferred as part of that transaction.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Data Security</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We implement appropriate technical and organizational security
|
||||||
|
measures to protect your information:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Encryption of data in transit and at rest</li>
|
||||||
|
<li>Secure access controls and authentication</li>
|
||||||
|
<li>Regular security assessments and updates</li>
|
||||||
|
<li>Employee training on data protection</li>
|
||||||
|
<li>Incident response procedures</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
However, no method of transmission over the internet or
|
||||||
|
electronic storage is 100% secure. While we strive to protect
|
||||||
|
your information, we cannot guarantee absolute security.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Data Retention</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We retain your personal information only for as long as
|
||||||
|
necessary to fulfill the purposes outlined in this Privacy
|
||||||
|
Policy, unless a longer retention period is required by law.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Factors we consider when determining retention periods include:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>The nature and sensitivity of the information</li>
|
||||||
|
<li>Legal and regulatory requirements</li>
|
||||||
|
<li>Business and operational needs</li>
|
||||||
|
<li>Your account status and activity</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Your Rights and Choices</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
Depending on your location, you may have the following rights
|
||||||
|
regarding your personal information:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>Access and Portability</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Request access to your personal information</li>
|
||||||
|
<li>Receive a copy of your data in a portable format</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Correction and Updates</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Correct inaccurate or incomplete information</li>
|
||||||
|
<li>Update your account information at any time</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Deletion</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Request deletion of your personal information</li>
|
||||||
|
<li>Close your account and remove your data</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Restriction and Objection</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Restrict the processing of your information</li>
|
||||||
|
<li>Object to certain uses of your data</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
To exercise these rights, please contact us using the
|
||||||
|
information provided in the "Contact Us" section
|
||||||
|
below.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Cookies and Tracking Technologies</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>We use cookies and similar technologies to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Remember your preferences and settings</li>
|
||||||
|
<li>Authenticate your account</li>
|
||||||
|
<li>Analyze usage patterns and improve our Service</li>
|
||||||
|
<li>Provide personalized content and features</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
You can control cookies through your browser settings. However,
|
||||||
|
disabling cookies may affect the functionality of our Service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>Types of Cookies We Use</h4>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Essential Cookies:</strong> Required for the Service
|
||||||
|
to function properly
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Analytics Cookies:</strong> Help us understand how you
|
||||||
|
use our Service
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Preference Cookies:</strong> Remember your settings
|
||||||
|
and preferences
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Third-Party Links and Services</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
Our Service may contain links to third-party websites or
|
||||||
|
integrate with third-party services. We are not responsible for
|
||||||
|
the privacy practices of these third parties.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We encourage you to read the privacy policies of any third-party
|
||||||
|
services you use in connection with our Service.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Children's Privacy</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
Our Service is not intended for children under the age of 13. We
|
||||||
|
do not knowingly collect personal information from children
|
||||||
|
under 13.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you are a parent or guardian and believe your child has
|
||||||
|
provided us with personal information, please contact us
|
||||||
|
immediately so we can remove such information.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>International Data Transfers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
Your information may be transferred to and processed in
|
||||||
|
countries other than your own. We ensure that such transfers
|
||||||
|
comply with applicable data protection laws.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When we transfer your information internationally, we implement
|
||||||
|
appropriate safeguards to protect your data, including:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Standard contractual clauses</li>
|
||||||
|
<li>Adequacy decisions by relevant authorities</li>
|
||||||
|
<li>Certified privacy frameworks</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Changes to This Privacy Policy</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We may update this Privacy Policy from time to time. We will
|
||||||
|
notify you of any material changes by:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Posting the updated policy on our Service</li>
|
||||||
|
<li>Sending you an email notification</li>
|
||||||
|
<li>Displaying a prominent notice on our Service</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Your continued use of our Service after any changes indicates
|
||||||
|
your acceptance of the updated Privacy Policy.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Contact Us</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
If you have questions about this Privacy Policy or our privacy
|
||||||
|
practices, please contact us at:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Email: privacy@beenvoice.com</li>
|
||||||
|
<li>Address: [Your Business Address]</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
We will respond to your inquiries within a reasonable timeframe
|
||||||
|
and in accordance with applicable law.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export default function TermsOfServicePage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-background min-h-screen">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-card border-b">
|
||||||
|
<div className="container mx-auto max-w-4xl px-6 py-6">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Link href="/auth/signin">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Terms of Service</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Last updated: {new Date().toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="container mx-auto max-w-4xl px-6 py-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Agreement to Terms</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
These Terms of Service ("Terms") govern your use of the
|
||||||
|
beenvoice platform and services (the "Service") operated by
|
||||||
|
beenvoice ("us", "we", or "our").
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
By accessing or using our Service, you agree to be bound by
|
||||||
|
these Terms. If you disagree with any part of these terms, then
|
||||||
|
you may not access the Service.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Description of Service</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
beenvoice is a web-based invoicing platform that allows users
|
||||||
|
to:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Create and manage professional invoices</li>
|
||||||
|
<li>Track client information and billing details</li>
|
||||||
|
<li>Monitor payment status and financial metrics</li>
|
||||||
|
<li>Generate reports and analytics</li>
|
||||||
|
<li>Manage business profiles and settings</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>User Accounts</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
When you create an account with us, you must provide information
|
||||||
|
that is accurate, complete, and current at all times. You are
|
||||||
|
responsible for safeguarding the password and for all activities
|
||||||
|
that occur under your account.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You agree not to disclose your password to any third party. You
|
||||||
|
must notify us immediately upon becoming aware of any breach of
|
||||||
|
security or unauthorized use of your account.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Acceptable Use</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>You agree not to use the Service:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
For any unlawful purpose or to solicit others to perform
|
||||||
|
unlawful acts
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
To violate any international, federal, provincial, or state
|
||||||
|
regulations, rules, laws, or local ordinances
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
To infringe upon or violate our intellectual property rights
|
||||||
|
or the intellectual property rights of others
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
To harass, abuse, insult, harm, defame, slander, disparage,
|
||||||
|
intimidate, or discriminate
|
||||||
|
</li>
|
||||||
|
<li>To submit false or misleading information</li>
|
||||||
|
<li>
|
||||||
|
To upload or transmit viruses or any other type of malicious
|
||||||
|
code
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
To spam, phish, pharm, pretext, spider, crawl, or scrape
|
||||||
|
</li>
|
||||||
|
<li>For any obscene or immoral purpose</li>
|
||||||
|
<li>
|
||||||
|
To interfere with or circumvent the security features of the
|
||||||
|
Service
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Data and Privacy</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
Your privacy is important to us. Please review our Privacy
|
||||||
|
Policy, which also governs your use of the Service, to
|
||||||
|
understand our practices.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You retain ownership of your data. We will not sell, rent, or
|
||||||
|
share your personal information with third parties without your
|
||||||
|
explicit consent, except as described in our Privacy Policy.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You are responsible for backing up your data. While we implement
|
||||||
|
regular backups, we recommend you maintain your own copies of
|
||||||
|
important information.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Payment Terms</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
Some aspects of the Service may require payment. You will be
|
||||||
|
charged according to your subscription plan. All fees are
|
||||||
|
non-refundable unless otherwise stated.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We may change our fees at any time. We will provide you with
|
||||||
|
reasonable notice of any fee changes by posting the new fees on
|
||||||
|
the Service or sending you email notification.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you fail to pay any fees when due, we may suspend or
|
||||||
|
terminate your access to the Service until payment is made.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Intellectual Property Rights</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
The Service and its original content, features, and
|
||||||
|
functionality are and will remain the exclusive property of
|
||||||
|
beenvoice and its licensors. The Service is protected by
|
||||||
|
copyright, trademark, and other laws.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Our trademarks and trade dress may not be used in connection
|
||||||
|
with any product or service without our prior written consent.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Termination</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We may terminate or suspend your account and bar access to the
|
||||||
|
Service immediately, without prior notice or liability, under
|
||||||
|
our sole discretion, for any reason whatsoever and without
|
||||||
|
limitation, including but not limited to a breach of the Terms.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you wish to terminate your account, you may simply
|
||||||
|
discontinue using the Service and contact us to request account
|
||||||
|
deletion.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Upon termination, your right to use the Service will cease
|
||||||
|
immediately. If you wish to terminate your account, you may
|
||||||
|
simply discontinue using the Service.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Disclaimer of Warranties</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
The information on this Service is provided on an "as
|
||||||
|
is" basis. To the fullest extent permitted by law, we
|
||||||
|
exclude all representations, warranties, and conditions relating
|
||||||
|
to our Service and the use of this Service.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Nothing in this disclaimer will limit or exclude our or your
|
||||||
|
liability for death or personal injury resulting from
|
||||||
|
negligence, fraud, or fraudulent misrepresentation.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Limitation of Liability</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
In no event shall beenvoice, nor its directors, employees,
|
||||||
|
partners, agents, suppliers, or affiliates, be liable for any
|
||||||
|
indirect, incidental, special, consequential, or punitive
|
||||||
|
damages, including without limitation, loss of profits, data,
|
||||||
|
use, goodwill, or other intangible losses, resulting from your
|
||||||
|
use of the Service.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Governing Law</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
These Terms shall be interpreted and governed by the laws of the
|
||||||
|
jurisdiction in which beenvoice operates, without regard to its
|
||||||
|
conflict of law provisions.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Our failure to enforce any right or provision of these Terms
|
||||||
|
will not be considered a waiver of those rights.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Changes to Terms</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
We reserve the right, at our sole discretion, to modify or
|
||||||
|
replace these Terms at any time. If a revision is material, we
|
||||||
|
will provide at least 30 days notice prior to any new terms
|
||||||
|
taking effect.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
What constitutes a material change will be determined at our
|
||||||
|
sole discretion. By continuing to access or use our Service
|
||||||
|
after any revisions become effective, you agree to be bound by
|
||||||
|
the revised terms.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Contact Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="prose prose-sm max-w-none">
|
||||||
|
<p>
|
||||||
|
If you have any questions about these Terms of Service, please
|
||||||
|
contact us at:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Email: legal@beenvoice.com</li>
|
||||||
|
<li>Address: [Your Business Address]</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
import { auth } from "~/lib/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth);
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { handlers } from "~/server/auth";
|
|
||||||
|
|
||||||
export const { GET, POST } = handlers;
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { users } from "~/server/db/schema";
|
||||||
|
import { Resend } from "resend";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { email } = (await request.json()) as { email: string };
|
||||||
|
|
||||||
|
if (!email || typeof email !== "string") {
|
||||||
|
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid email format" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, email.toLowerCase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Always return success to prevent email enumeration attacks
|
||||||
|
// Don't reveal whether the user exists or not
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"If an account with that email exists, password reset instructions have been sent.",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate reset token
|
||||||
|
const resetToken = crypto.randomBytes(32).toString("hex");
|
||||||
|
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||||
|
|
||||||
|
// Update user with reset token
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
resetToken,
|
||||||
|
resetTokenExpiry,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
if (!env.RESEND_API_KEY) {
|
||||||
|
console.warn(
|
||||||
|
"Password reset requested, but RESEND_API_KEY is not configured.",
|
||||||
|
);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"If an account with that email exists, password reset instructions have been sent.",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send password reset email using Resend
|
||||||
|
try {
|
||||||
|
const resend = new Resend(env.RESEND_API_KEY);
|
||||||
|
const resetUrl = `${process.env.BETTER_AUTH_URL ?? "http://localhost:3000"}/auth/reset-password?token=${resetToken}`;
|
||||||
|
|
||||||
|
const emailTemplate = generatePasswordResetEmailTemplate({
|
||||||
|
userEmail: email,
|
||||||
|
userName: user.name ?? undefined,
|
||||||
|
resetToken,
|
||||||
|
resetUrl,
|
||||||
|
expiryHours: 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: "beenvoice <noreply@beenvoice.com>",
|
||||||
|
to: email,
|
||||||
|
subject: emailTemplate.subject,
|
||||||
|
html: emailTemplate.html,
|
||||||
|
text: emailTemplate.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Password reset email sent to: ${email}`);
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error("Failed to send password reset email:", emailError);
|
||||||
|
// Continue execution - don't fail the request if email fails
|
||||||
|
// This prevents revealing whether an account exists based on email delivery
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"If an account with that email exists, password reset instructions have been sent.",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Password reset error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "An error occurred while processing your request" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,59 +2,97 @@ import bcrypt from "bcryptjs";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { env } from "~/env";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { users } from "~/server/db/schema";
|
import { accounts, users } from "~/server/db/schema";
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
firstName: z.string().min(1, "First name is required"),
|
firstName: z.string().trim().min(1, "First name is required"),
|
||||||
lastName: z.string().min(1, "Last name is required"),
|
lastName: z.string().trim().min(1, "Last name is required"),
|
||||||
email: z.string().email("Invalid email address"),
|
email: z.string().email("Invalid email address"),
|
||||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fieldLabels: Record<string, string> = {
|
||||||
|
firstName: "First name",
|
||||||
|
lastName: "Last name",
|
||||||
|
email: "Email address",
|
||||||
|
password: "Password",
|
||||||
|
};
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json() as z.infer<typeof registerSchema>;
|
if (env.DISABLE_SIGNUPS === true) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "New account registration is currently disabled" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as unknown;
|
||||||
const { firstName, lastName, email, password } = registerSchema.parse(body);
|
const { firstName, lastName, email, password } = registerSchema.parse(body);
|
||||||
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
// Check if user already exists
|
// Check if user already exists
|
||||||
const existingUser = await db.query.users.findFirst({
|
const existingUser = await db.query.users.findFirst({
|
||||||
where: eq(users.email, email),
|
where: eq(users.email, normalizedEmail),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "User with this email already exists" },
|
{ error: "User with this email already exists" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash password
|
// Hash password
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
// Create user
|
await db.transaction(async (tx) => {
|
||||||
await db.insert(users).values({
|
const [user] = await tx
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
name: `${firstName} ${lastName}`,
|
name: `${firstName} ${lastName}`,
|
||||||
email,
|
email: normalizedEmail,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
|
})
|
||||||
|
.returning({ id: users.id });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Failed to create user");
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(accounts).values({
|
||||||
|
userId: user.id,
|
||||||
|
accountId: user.id,
|
||||||
|
providerId: "credential",
|
||||||
|
password: hashedPassword,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: "User created successfully" },
|
{ message: "User created successfully" },
|
||||||
{ status: 201 }
|
{ status: 201 },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
|
const issue = error.errors[0];
|
||||||
|
const field = issue?.path[0];
|
||||||
|
const fallback =
|
||||||
|
typeof field === "string"
|
||||||
|
? `${fieldLabels[field] ?? field} is required`
|
||||||
|
: "Please check the registration form";
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.errors[0]?.message ?? "Validation error" },
|
{ error: issue?.message === "Required" ? fallback : issue?.message },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("Registration error:", error);
|
console.error("Registration error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal server error" },
|
{ error: "Internal server error" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq, and, gt } from "drizzle-orm";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { accounts, users } from "~/server/db/schema";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { token, password } = (await request.json()) as {
|
||||||
|
token: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!token || typeof token !== "string") {
|
||||||
|
return NextResponse.json({ error: "Token is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password || typeof password !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Password is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Password must be at least 8 characters long" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user with valid reset token that hasn't expired
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(users.resetToken, token),
|
||||||
|
gt(users.resetTokenExpiry, new Date()),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid or expired token" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the new password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
password: hashedPassword,
|
||||||
|
resetToken: null,
|
||||||
|
resetTokenExpiry: null,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
const credentialAccount = await tx.query.accounts.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(accounts.userId, user.id),
|
||||||
|
eq(accounts.providerId, "credential"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (credentialAccount) {
|
||||||
|
await tx
|
||||||
|
.update(accounts)
|
||||||
|
.set({
|
||||||
|
password: hashedPassword,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(accounts.id, credentialAccount.id));
|
||||||
|
} else {
|
||||||
|
await tx.insert(accounts).values({
|
||||||
|
userId: user.id,
|
||||||
|
accountId: user.id,
|
||||||
|
providerId: "credential",
|
||||||
|
password: hashedPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: "Password has been reset successfully",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Password reset error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "An error occurred while resetting your password" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq, and, gt } from "drizzle-orm";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { users } from "~/server/db/schema";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { token } = (await request.json()) as { token: string };
|
||||||
|
|
||||||
|
if (!token || typeof token !== "string") {
|
||||||
|
return NextResponse.json({ error: "Token is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user with valid reset token that hasn't expired
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(users.resetToken, token),
|
||||||
|
gt(users.resetTokenExpiry, new Date()),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid or expired token" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ valid: true }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Token validation error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "An error occurred while validating the token" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, Suspense } from "react";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Logo } from "~/components/branding/logo";
|
||||||
|
import { LegalModal } from "~/components/ui/legal-modal";
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
Shield,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
function ForgotPasswordForm() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/forgot-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await response.json()) as { error?: string };
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSent(true);
|
||||||
|
toast.success("Password reset instructions sent to your email");
|
||||||
|
} else {
|
||||||
|
toast.error(data.error ?? "Failed to send reset email");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("An error occurred. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||||
|
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||||
|
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||||
|
{/* Hero Section - Hidden on mobile */}
|
||||||
|
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Logo size="xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||||
|
Check your
|
||||||
|
<span className="text-primary"> email inbox</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
We've sent password reset instructions to your email
|
||||||
|
address. Follow the link to create a new password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Mail className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Check your inbox</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Look for an email from beenvoice with reset instructions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Clock className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Link expires soon</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
The reset link is valid for 24 hours only
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Shield className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Secure Process</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Your account security is our top priority
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-primary/5 flex items-center space-x-4 rounded-lg p-4">
|
||||||
|
<CheckCircle className="text-primary h-8 w-8" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Email sent successfully</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Follow the instructions in your email to reset your
|
||||||
|
password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||||
|
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="flex justify-center md:hidden">
|
||||||
|
<Logo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<div className="bg-primary/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||||
|
<CheckCircle className="text-primary h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Check your email</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
We've sent password reset instructions to{" "}
|
||||||
|
<span className="font-medium">{email}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/50 space-y-3 rounded-lg p-4">
|
||||||
|
<h3 className="font-semibold">What's next?</h3>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
<li className="flex items-start space-x-2">
|
||||||
|
<span className="text-primary">1.</span>
|
||||||
|
<span>Check your email inbox (and spam folder)</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start space-x-2">
|
||||||
|
<span className="text-primary">2.</span>
|
||||||
|
<span>Click the reset link in the email</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start space-x-2">
|
||||||
|
<span className="text-primary">3.</span>
|
||||||
|
<span>Create a new secure password</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setSent(false);
|
||||||
|
setEmail("");
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 w-full"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Try a different email
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<a href="/auth/signin">
|
||||||
|
<Button className="h-11 w-full">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Sign In
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground text-center text-xs">
|
||||||
|
Didn't receive the email? Check your spam folder or{" "}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSent(false);
|
||||||
|
toast.info("You can try sending the email again");
|
||||||
|
}}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
try again
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||||
|
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||||
|
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||||
|
{/* Hero Section - Hidden on mobile */}
|
||||||
|
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Logo size="xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||||
|
Forgot your
|
||||||
|
<span className="text-primary"> password?</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
No worries! Enter your email address and we'll send you
|
||||||
|
instructions to reset your password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Mail className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Email Instructions</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
We'll send a secure link to your email address
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Clock className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Quick Process</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Reset your password in just a few clicks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Shield className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Secure & Safe</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Your account security is our top priority
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Forgot Password Form */}
|
||||||
|
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||||
|
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="flex justify-center md:hidden">
|
||||||
|
<Logo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center md:text-left">
|
||||||
|
<h1 className="text-2xl font-bold">Forgot Password</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Enter your email and we'll send you reset instructions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email Address</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="h-11 pl-10"
|
||||||
|
placeholder="Enter your email address"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-11 w-full"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
|
||||||
|
<span>Sending instructions...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span>Send Reset Instructions</span>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="bg-muted/50 rounded-lg p-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Mail className="text-primary mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium">Check your spam folder</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Sometimes our emails end up in spam or promotions folders
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<a
|
||||||
|
href="/auth/signin"
|
||||||
|
className="text-primary inline-flex items-center space-x-1 text-sm font-medium hover:underline"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3 w-3" />
|
||||||
|
<span>Back to Sign In</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground text-center text-xs">
|
||||||
|
Remember your password?{" "}
|
||||||
|
<a
|
||||||
|
href="/auth/signin"
|
||||||
|
className="text-primary font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Sign in instead
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground text-center text-xs leading-relaxed">
|
||||||
|
By using our service, you agree to our{" "}
|
||||||
|
<LegalModal
|
||||||
|
type="terms"
|
||||||
|
trigger={
|
||||||
|
<span className="text-primary inline cursor-pointer hover:underline">
|
||||||
|
Terms of Service
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>{" "}
|
||||||
|
and{" "}
|
||||||
|
<LegalModal
|
||||||
|
type="privacy"
|
||||||
|
trigger={
|
||||||
|
<span className="text-primary inline cursor-pointer hover:underline">
|
||||||
|
Privacy Policy
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<ForgotPasswordForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
+151
-86
@@ -1,20 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useState, Suspense } from "react";
|
import { useState, Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Logo } from "~/components/branding/logo";
|
import { Logo } from "~/components/branding/logo";
|
||||||
import { User, Mail, Lock, ArrowRight } from "lucide-react";
|
import { LegalModal } from "~/components/ui/legal-modal";
|
||||||
|
import { Mail, Lock, ArrowRight, User, Clock, Rocket, Zap } from "lucide-react";
|
||||||
|
|
||||||
function RegisterForm() {
|
function RegisterForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
|
||||||
const [firstName, setFirstName] = useState("");
|
const [firstName, setFirstName] = useState("");
|
||||||
const [lastName, setLastName] = useState("");
|
const [lastName, setLastName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -24,6 +23,7 @@ function RegisterForm() {
|
|||||||
async function handleRegister(e: React.FormEvent) {
|
async function handleRegister(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const res = await fetch("/api/auth/register", {
|
const res = await fetch("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -34,44 +34,102 @@ function RegisterForm() {
|
|||||||
password,
|
password,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success("Account created successfully! Please sign in.");
|
toast.success("Account created successfully! Please sign in.");
|
||||||
const signInUrl =
|
router.push("/auth/signin");
|
||||||
callbackUrl !== "/dashboard"
|
|
||||||
? `/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
|
||||||
: "/auth/signin";
|
|
||||||
router.push(signInUrl);
|
|
||||||
} else {
|
} else {
|
||||||
const error = await res.text();
|
const data = (await res.json()) as { error?: string };
|
||||||
toast.error(error || "Failed to create account");
|
toast.error(data.error ?? "Registration failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-container">
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||||
<div className="auth-form-container">
|
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:border md:shadow-lg">
|
||||||
{/* Logo and Welcome */}
|
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||||
<div className="auth-header">
|
{/* Hero Section - Hidden on mobile */}
|
||||||
<Logo size="lg" className="mx-auto" />
|
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||||
<div>
|
<div className="space-y-8">
|
||||||
<h1 className="auth-title">Join beenvoice</h1>
|
<div className="space-y-4">
|
||||||
<p className="auth-subtitle">Create your account to get started</p>
|
<div className="flex items-center space-x-2">
|
||||||
|
<Logo size="xl" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||||
|
Start your
|
||||||
|
<span className="text-primary"> invoicing journey</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
Join thousands of freelancers and small businesses who trust
|
||||||
|
beenvoice to manage their invoicing and get paid faster.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Registration Form */}
|
<div className="grid gap-4">
|
||||||
<Card className="auth-card">
|
<div className="flex items-start space-x-4">
|
||||||
<CardHeader className="space-y-1">
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
<CardTitle className="auth-card-title">Create Account</CardTitle>
|
<Rocket className="text-primary h-5 w-5" />
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div className="space-y-1">
|
||||||
<form onSubmit={handleRegister} className="auth-form">
|
<h3 className="font-semibold">Quick Setup</h3>
|
||||||
<div className="auth-input-grid">
|
<p className="text-muted-foreground text-sm">
|
||||||
<div className="auth-input-group">
|
Get started in minutes with our intuitive setup wizard
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Zap className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Fast Payments</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Professional invoices that get you paid 3x faster
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Clock className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Time Tracking</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Track time and convert it to accurate invoices instantly
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sign Up Form */}
|
||||||
|
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||||
|
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="flex justify-center md:hidden">
|
||||||
|
<Logo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center md:text-left">
|
||||||
|
<h1 className="text-2xl font-bold">Create your account</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Supercharge your invoicing today
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleRegister} className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
<Label htmlFor="firstName">First Name</Label>
|
<Label htmlFor="firstName">First Name</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="auth-input-icon" />
|
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
id="firstName"
|
id="firstName"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -79,118 +137,125 @@ function RegisterForm() {
|
|||||||
onChange={(e) => setFirstName(e.target.value)}
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
className="form-input-with-icon"
|
className="h-11 pl-10"
|
||||||
placeholder="First name"
|
placeholder="John"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-input-group">
|
|
||||||
|
<div className="space-y-2">
|
||||||
<Label htmlFor="lastName">Last Name</Label>
|
<Label htmlFor="lastName">Last Name</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="auth-input-icon" />
|
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
id="lastName"
|
id="lastName"
|
||||||
type="text"
|
type="text"
|
||||||
value={lastName}
|
value={lastName}
|
||||||
onChange={(e) => setLastName(e.target.value)}
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
required
|
required
|
||||||
className="form-input-with-icon"
|
className="h-11 pl-10"
|
||||||
placeholder="Last name"
|
placeholder="Doe"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-input-group">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email Address</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="auth-input-icon" />
|
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
className="form-input-with-icon"
|
className="h-11 pl-10"
|
||||||
placeholder="Enter your email"
|
placeholder="john@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-input-group">
|
|
||||||
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="auth-input-icon" />
|
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
minLength={6}
|
className="h-11 pl-10"
|
||||||
className="form-input-with-icon"
|
placeholder="Create a strong password"
|
||||||
placeholder="Create a password"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="auth-password-help">
|
<p className="text-muted-foreground text-xs">
|
||||||
Must be at least 6 characters
|
Must be at least 8 characters long
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="auth-submit-btn"
|
className="h-11 w-full"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
"Creating account..."
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
|
||||||
|
<span>Creating account...</span>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex items-center space-x-2">
|
||||||
Create Account
|
<span>Create Account</span>
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<div className="auth-footer-text">
|
|
||||||
<span className="text-muted-foreground">
|
<div className="text-center text-sm">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
|
<a
|
||||||
|
href="/auth/signin"
|
||||||
|
className="text-primary font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground text-center text-xs leading-relaxed">
|
||||||
|
By creating an account, you agree to our{" "}
|
||||||
|
<LegalModal
|
||||||
|
type="terms"
|
||||||
|
trigger={
|
||||||
|
<span className="text-primary inline cursor-pointer hover:underline">
|
||||||
|
Terms of Service
|
||||||
</span>
|
</span>
|
||||||
<Link href="/auth/signin" className="auth-footer-link">
|
}
|
||||||
Sign in here
|
/>{" "}
|
||||||
</Link>
|
and{" "}
|
||||||
|
<LegalModal
|
||||||
|
type="privacy"
|
||||||
|
trigger={
|
||||||
|
<span className="text-primary inline cursor-pointer hover:underline">
|
||||||
|
Privacy Policy
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<div className="auth-features">
|
|
||||||
<p className="welcome-description">Start invoicing like a pro</p>
|
|
||||||
<div className="auth-features-list">
|
|
||||||
<span>✓ Free to start</span>
|
|
||||||
<span>✓ No credit card</span>
|
|
||||||
<span>✓ Cancel anytime</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
fallback={
|
|
||||||
<div className="auth-container">
|
|
||||||
<div className="auth-form-container">
|
|
||||||
<div className="auth-header">
|
|
||||||
<Logo size="lg" className="mx-auto" />
|
|
||||||
<div>
|
|
||||||
<h1 className="auth-title">Join beenvoice</h1>
|
|
||||||
<p className="auth-subtitle">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<RegisterForm />
|
<RegisterForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, Suspense, useEffect } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Logo } from "~/components/branding/logo";
|
||||||
|
import { LegalModal } from "~/components/ui/legal-modal";
|
||||||
|
import {
|
||||||
|
Lock,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle,
|
||||||
|
Shield,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
function ResetPasswordForm() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [tokenValid, setTokenValid] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setTokenValid(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token on page load
|
||||||
|
const validateToken = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/validate-reset-token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setTokenValid(true);
|
||||||
|
} else {
|
||||||
|
setTokenValid(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTokenValid(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void validateToken();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
toast.error("Invalid reset token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
toast.error("Password must be at least 8 characters long");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
toast.error("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/reset-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await response.json()) as { error?: string };
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSuccess(true);
|
||||||
|
toast.success("Password reset successfully!");
|
||||||
|
} else {
|
||||||
|
toast.error(data.error ?? "Failed to reset password");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("An error occurred. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenValid === null) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"></div>
|
||||||
|
<p className="text-muted-foreground mt-4">
|
||||||
|
Validating reset token...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenValid === false) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||||
|
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||||
|
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||||
|
{/* Hero Section - Hidden on mobile */}
|
||||||
|
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Logo size="xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||||
|
Invalid or
|
||||||
|
<span className="text-destructive"> expired link</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
This password reset link is either invalid or has expired.
|
||||||
|
Please request a new password reset.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-destructive/10 rounded-lg p-2">
|
||||||
|
<Shield className="text-destructive h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Security First</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Reset links expire after 24 hours for your security
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Form */}
|
||||||
|
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||||
|
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="flex justify-center md:hidden">
|
||||||
|
<Logo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<div className="bg-destructive/10 justify-content mx-auto mb-4 flex h-16 w-16 items-center rounded-full">
|
||||||
|
<Shield className="text-destructive mx-auto h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Link Expired</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
This password reset link is no longer valid
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<a href="/auth/forgot-password">
|
||||||
|
<Button className="h-11 w-full">
|
||||||
|
Request New Reset Link
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/auth/signin">
|
||||||
|
<Button variant="outline" className="h-11 w-full">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Sign In
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||||
|
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||||
|
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||||
|
{/* Hero Section - Hidden on mobile */}
|
||||||
|
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Logo size="xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||||
|
Password
|
||||||
|
<span className="text-primary"> reset complete</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
Your password has been successfully reset. You can now
|
||||||
|
sign in with your new password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-primary/5 rounded-lg p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<CheckCircle className="text-primary h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Security Updated</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Your account is now secured with your new password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success Form */}
|
||||||
|
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||||
|
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="flex justify-center md:hidden">
|
||||||
|
<Logo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<div className="bg-primary/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||||
|
<CheckCircle className="text-primary h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
Password Reset Complete
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Your password has been successfully updated
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<a href="/auth/signin">
|
||||||
|
<Button className="h-11 w-full">
|
||||||
|
<ArrowRight className="mr-2 h-4 w-4" />
|
||||||
|
Sign In Now
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
||||||
|
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
|
||||||
|
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||||
|
{/* Hero Section - Hidden on mobile */}
|
||||||
|
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Logo size="xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-4xl">
|
||||||
|
Create your
|
||||||
|
<span className="text-primary"> new password</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
Choose a strong password to secure your beenvoice account.
|
||||||
|
Make sure it's something you'll remember.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Shield className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Secure Password</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Use at least 8 characters with a mix of letters and
|
||||||
|
numbers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
|
<Lock className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">Account Safety</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Your new password will immediately secure your account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Password Form */}
|
||||||
|
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||||
|
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="flex justify-center md:hidden">
|
||||||
|
<Logo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center md:text-left">
|
||||||
|
<h1 className="text-2xl font-bold">Reset Password</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Enter your new password below
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">New Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="h-11 pr-10 pl-10"
|
||||||
|
placeholder="Enter new password"
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 z-10 -translate-y-1/2"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Must be at least 8 characters long
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="h-11 pr-10 pl-10"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setShowConfirmPassword(!showConfirmPassword)
|
||||||
|
}
|
||||||
|
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 z-10 -translate-y-1/2"
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-11 w-full"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
|
||||||
|
<span>Updating password...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span>Update Password</span>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<a
|
||||||
|
href="/auth/signin"
|
||||||
|
className="text-primary inline-flex items-center space-x-1 text-sm font-medium hover:underline"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3 w-3" />
|
||||||
|
<span>Back to Sign In</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground text-center text-xs leading-relaxed">
|
||||||
|
By resetting your password, you agree to our{" "}
|
||||||
|
<LegalModal
|
||||||
|
type="terms"
|
||||||
|
trigger={
|
||||||
|
<span className="text-primary inline cursor-pointer hover:underline">
|
||||||
|
Terms of Service
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>{" "}
|
||||||
|
and{" "}
|
||||||
|
<LegalModal
|
||||||
|
type="privacy"
|
||||||
|
trigger={
|
||||||
|
<span className="text-primary inline cursor-pointer hover:underline">
|
||||||
|
Privacy Policy
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<ResetPasswordForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,156 +1,11 @@
|
|||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
import { env } from "~/env";
|
||||||
import Link from "next/link";
|
import { SignInForm } from "./signin-form";
|
||||||
import { useState, Suspense } from "react";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { signIn } from "next-auth/react";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Label } from "~/components/ui/label";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Logo } from "~/components/branding/logo";
|
|
||||||
import { Mail, Lock, ArrowRight } from "lucide-react";
|
|
||||||
|
|
||||||
function SignInForm() {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
async function handleSignIn(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const result = await signIn("credentials", {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
redirect: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
if (result?.error) {
|
|
||||||
toast.error("Invalid email or password");
|
|
||||||
} else {
|
|
||||||
toast.success("Signed in successfully!");
|
|
||||||
router.push(callbackUrl);
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-gradient-auth flex min-h-screen items-center justify-center p-4">
|
|
||||||
<div className="w-full max-w-md space-y-8">
|
|
||||||
{/* Logo and Welcome */}
|
|
||||||
<div className="space-y-4 text-center">
|
|
||||||
<Logo size="lg" className="mx-auto" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-foreground text-2xl font-bold">Welcome back</h1>
|
|
||||||
<p className="text-muted-foreground mt-2">
|
|
||||||
Sign in to your beenvoice account
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sign In Form */}
|
|
||||||
<Card className="card-primary">
|
|
||||||
<CardHeader className="space-y-1">
|
|
||||||
<CardTitle className="text-center text-xl">Sign In</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSignIn} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="form-icon-left" />
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
className="form-input-with-icon"
|
|
||||||
placeholder="Enter your email"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Lock className="form-icon-left" />
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
className="form-input-with-icon"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" className="w-full" disabled={loading}>
|
|
||||||
{loading ? (
|
|
||||||
"Signing in..."
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Sign In
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
<div className="mt-6 text-center text-sm">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Don't have an account?{" "}
|
|
||||||
</span>
|
|
||||||
<Link href="/auth/register" className="nav-link-brand">
|
|
||||||
Create one now
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<div className="space-y-4 text-center">
|
|
||||||
<p className="welcome-description">
|
|
||||||
Simple invoicing for freelancers and small businesses
|
|
||||||
</p>
|
|
||||||
<div className="welcome-feature-list">
|
|
||||||
<span>✓ Easy client management</span>
|
|
||||||
<span>✓ Professional invoices</span>
|
|
||||||
<span>✓ Payment tracking</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SignInPage() {
|
export default function SignInPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
fallback={
|
<SignInForm allowRegistration={env.DISABLE_SIGNUPS !== true} />
|
||||||
<div className="bg-gradient-auth flex min-h-screen items-center justify-center p-4">
|
|
||||||
<div className="w-full max-w-md space-y-8">
|
|
||||||
<div className="space-y-4 text-center">
|
|
||||||
<Logo size="lg" className="mx-auto" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-foreground text-2xl font-bold">
|
|
||||||
Welcome back
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-2">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SignInForm />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, Suspense } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { authClient } from "~/lib/auth-client";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Logo } from "~/components/branding/logo";
|
||||||
|
import { LegalModal } from "~/components/ui/legal-modal";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Lock,
|
||||||
|
ArrowRight,
|
||||||
|
Users,
|
||||||
|
FileText,
|
||||||
|
TrendingUp,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface SignInFormProps {
|
||||||
|
allowRegistration: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignInForm({ allowRegistration }: SignInFormProps) {
|
||||||
|
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||||
|
const signupDisabled = searchParams.get("signup") === "disabled";
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSignIn(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const { error } = await authClient.signIn.email({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message ?? "Invalid email or password");
|
||||||
|
} else {
|
||||||
|
toast.success("Signed in successfully!");
|
||||||
|
router.push(callbackUrl);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSocialSignIn() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await authClient.signIn.oauth2({
|
||||||
|
providerId: "authentik",
|
||||||
|
callbackURL: callbackUrl,
|
||||||
|
});
|
||||||
|
// The signIn.sso method will automatically redirect to the SSO provider
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SSO Error]", error);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
|
||||||
|
{/* Blob Background */}
|
||||||
|
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
|
||||||
|
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="md:bg-background/80 md:border-border/50 mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:rounded-3xl md:border md:shadow-2xl md:backdrop-blur-xl">
|
||||||
|
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||||
|
{/* Hero Section - Hidden on mobile */}
|
||||||
|
<div className="bg-primary/5 border-border/50 relative hidden border-r md:flex md:flex-col md:justify-center md:p-12">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Logo size="xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="font-heading text-3xl font-bold lg:text-4xl">
|
||||||
|
Welcome back to your
|
||||||
|
<span className="text-primary italic">
|
||||||
|
{" "}
|
||||||
|
invoicing workspace
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
Continue managing your clients and creating professional
|
||||||
|
invoices that get you paid faster.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-xl p-3">
|
||||||
|
<Users className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-foreground font-semibold">
|
||||||
|
Client Management
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Organize and track all your clients in one place
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-xl p-3">
|
||||||
|
<FileText className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-foreground font-semibold">
|
||||||
|
Professional Invoices
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Beautiful templates that get you paid faster
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="bg-primary/10 rounded-xl p-3">
|
||||||
|
<TrendingUp className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-foreground font-semibold">
|
||||||
|
Payment Tracking
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Monitor your income with real-time insights
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sign In Form */}
|
||||||
|
<div className="flex flex-col justify-center p-6 md:p-12">
|
||||||
|
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="flex justify-center md:hidden">
|
||||||
|
<Logo size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center md:text-left">
|
||||||
|
<h1 className="font-heading text-3xl font-bold">Sign In</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Enter your credentials to access your account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{signupDisabled && (
|
||||||
|
<div className="border-border bg-muted/50 text-muted-foreground rounded-lg border px-3 py-2 text-sm">
|
||||||
|
New account registration is currently disabled.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authentikEnabled && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
className="relative h-11 w-full rounded-xl"
|
||||||
|
onClick={handleSocialSignIn}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Shield className="mr-2 h-4 w-4" />
|
||||||
|
Sign in with Authentik
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<span className="border-border/50 w-full border-t" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-background text-muted-foreground px-2">
|
||||||
|
Or continue with
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSignIn} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email Address</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
|
||||||
|
placeholder="m@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<a
|
||||||
|
href="/auth/forgot-password"
|
||||||
|
className="text-primary text-sm hover:underline"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="shadow-primary/20 hover:shadow-primary/30 h-11 w-full rounded-xl text-base shadow-lg"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
|
||||||
|
<span>Signing in...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span>Sign In</span>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{allowRegistration && (
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<a
|
||||||
|
href="/auth/register"
|
||||||
|
className="text-primary font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-muted-foreground text-center text-xs leading-relaxed">
|
||||||
|
By signing in, you agree to our{" "}
|
||||||
|
<LegalModal
|
||||||
|
type="terms"
|
||||||
|
trigger={
|
||||||
|
<span className="text-primary inline cursor-pointer hover:underline">
|
||||||
|
Terms of Service
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>{" "}
|
||||||
|
and{" "}
|
||||||
|
<LegalModal
|
||||||
|
type="privacy"
|
||||||
|
trigger={
|
||||||
|
<span className="text-primary inline cursor-pointer hover:underline">
|
||||||
|
Privacy Policy
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignInPageClient() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<SignInForm allowRegistration />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { auth } from "~/server/auth";
|
|
||||||
import { HydrateClient } from "~/trpc/server";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { ClientForm } from "~/components/forms/client-form";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface EditClientPageProps {
|
|
||||||
params: Promise<{
|
|
||||||
id: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function EditClientPage({ params }: EditClientPageProps) {
|
|
||||||
const { id } = await params;
|
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<h1 className="mb-4 text-4xl font-bold">Access Denied</h1>
|
|
||||||
<p className="text-muted-foreground mb-8">
|
|
||||||
Please sign in to edit clients
|
|
||||||
</p>
|
|
||||||
<Link href="/api/auth/signin">
|
|
||||||
<Button size="lg">Sign In</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HydrateClient>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="mb-2 text-3xl font-bold">Edit Client</h2>
|
|
||||||
<p className="text-muted-foreground">Update client information</p>
|
|
||||||
</div>
|
|
||||||
<ClientForm mode="edit" clientId={id} />
|
|
||||||
</div>
|
|
||||||
</HydrateClient>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Navbar } from "~/components/layout/navbar";
|
|
||||||
import { Sidebar } from "~/components/layout/sidebar";
|
|
||||||
|
|
||||||
export default function ClientsLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Navbar />
|
|
||||||
<div className="flex">
|
|
||||||
<Sidebar />
|
|
||||||
<main className="flex-1 min-h-screen bg-background">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { auth } from "~/server/auth";
|
|
||||||
import { HydrateClient } from "~/trpc/server";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { ClientForm } from "~/components/forms/client-form";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default async function NewClientPage() {
|
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<h1 className="text-4xl font-bold mb-4">Access Denied</h1>
|
|
||||||
<p className="text-muted-foreground mb-8">Please sign in to create clients</p>
|
|
||||||
<Link href="/api/auth/signin">
|
|
||||||
<Button size="lg">Sign In</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HydrateClient>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-3xl font-bold mb-2">Add New Client</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Create a new client profile
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ClientForm mode="create" />
|
|
||||||
</div>
|
|
||||||
</HydrateClient>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { auth } from "~/server/auth";
|
|
||||||
import { api, HydrateClient } from "~/trpc/server";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { ClientList } from "~/components/data/client-list";
|
|
||||||
import { Plus } from "lucide-react";
|
|
||||||
|
|
||||||
export default async function ClientsPage() {
|
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<h1 className="text-4xl font-bold mb-4">Access Denied</h1>
|
|
||||||
<p className="text-muted-foreground mb-8">Please sign in to view clients</p>
|
|
||||||
<Link href="/api/auth/signin">
|
|
||||||
<Button size="lg">Sign In</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefetch clients data
|
|
||||||
void api.clients.getAll.prefetch();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HydrateClient>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-3xl font-bold mb-2">Clients</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage your client relationships
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ClientList />
|
|
||||||
</div>
|
|
||||||
</HydrateClient>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
TrendingDown,
|
||||||
|
TrendingUp,
|
||||||
|
Minus,
|
||||||
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
|
||||||
|
type IconName = "DollarSign" | "Clock" | "Users" | "TrendingDown";
|
||||||
|
|
||||||
|
interface AnimatedStatsCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
change: string;
|
||||||
|
trend: "up" | "down" | "neutral";
|
||||||
|
iconName: IconName;
|
||||||
|
description: string;
|
||||||
|
delay?: number;
|
||||||
|
isCurrency?: boolean;
|
||||||
|
numericValue?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
TrendingDown,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function AnimatedStatsCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
change,
|
||||||
|
trend,
|
||||||
|
iconName,
|
||||||
|
description,
|
||||||
|
delay = 0,
|
||||||
|
isCurrency = false,
|
||||||
|
numericValue,
|
||||||
|
}: AnimatedStatsCardProps) {
|
||||||
|
const Icon = iconMap[iconName];
|
||||||
|
|
||||||
|
let TrendIcon = Minus;
|
||||||
|
if (trend === "up") TrendIcon = TrendingUp;
|
||||||
|
if (trend === "down") TrendIcon = TrendingDown;
|
||||||
|
|
||||||
|
const isPositive = trend === "up";
|
||||||
|
const isNeutral = trend === "neutral";
|
||||||
|
|
||||||
|
// For now, always use the formatted value prop to ensure correct display
|
||||||
|
// Animation can be added back once the basic display is working correctly
|
||||||
|
const displayValue = value;
|
||||||
|
|
||||||
|
// Suppress unused parameter warnings for now
|
||||||
|
void delay;
|
||||||
|
void isCurrency;
|
||||||
|
void numericValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between space-y-0 pb-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Icon className="text-muted-foreground h-5 w-5" />
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{title}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-1 text-xs"
|
||||||
|
style={{
|
||||||
|
color: isNeutral
|
||||||
|
? "hsl(var(--muted-foreground))"
|
||||||
|
: isPositive
|
||||||
|
? "oklch(var(--chart-2))"
|
||||||
|
: "oklch(var(--chart-3))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrendIcon className="h-3 w-3" />
|
||||||
|
<span>{change}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="animate-count-up text-2xl font-bold">{displayValue}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">{description}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||||
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
|
||||||
|
interface Invoice {
|
||||||
|
id: string;
|
||||||
|
totalAmount: number;
|
||||||
|
status: string;
|
||||||
|
dueDate: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoiceStatusChartProps {
|
||||||
|
invoices: Invoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
||||||
|
// Process invoice data to create status breakdown
|
||||||
|
const statusData = invoices.reduce(
|
||||||
|
(acc, invoice) => {
|
||||||
|
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
acc[effectiveStatus] ??= {
|
||||||
|
status: effectiveStatus,
|
||||||
|
count: 0,
|
||||||
|
value: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
acc[effectiveStatus].count += 1;
|
||||||
|
acc[effectiveStatus].value += invoice.totalAmount;
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, { status: string; count: number; value: number }>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartData = Object.values(statusData).map((item) => ({
|
||||||
|
...item,
|
||||||
|
name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Use theme-aware colors
|
||||||
|
const COLORS = {
|
||||||
|
draft: "hsl(0, 0%, 60%)", // neutral grey - matches monthly metrics chart
|
||||||
|
sent: "hsl(217, 91%, 60%)", // vibrant blue
|
||||||
|
pending: "hsl(217, 91%, 60%)", // blue
|
||||||
|
paid: "hsl(142, 71%, 45%)", // vibrant green
|
||||||
|
overdue: "hsl(var(--destructive))", // red
|
||||||
|
};
|
||||||
|
// Animation / motion preferences
|
||||||
|
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||||
|
useAnimationPreferences();
|
||||||
|
const pieAnimationDuration = Math.round(
|
||||||
|
600 / (animationSpeedMultiplier || 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTooltip = ({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{
|
||||||
|
payload: { name: string; count: number; value: number };
|
||||||
|
}>;
|
||||||
|
}) => {
|
||||||
|
if (active && payload?.length) {
|
||||||
|
const data = payload[0]!.payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||||
|
<p className="font-medium">{data.name}</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{data.count} invoice{data.count !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{formatCurrency(data.value)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No invoice data available
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Status breakdown will appear here once you create invoices
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="h-48 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={40}
|
||||||
|
outerRadius={80}
|
||||||
|
stroke="none"
|
||||||
|
dataKey="count"
|
||||||
|
isAnimationActive={!prefersReducedMotion}
|
||||||
|
animationDuration={pieAnimationDuration}
|
||||||
|
animationEasing="ease-out"
|
||||||
|
>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={COLORS[entry.status as keyof typeof COLORS]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{chartData.map((item) => (
|
||||||
|
<div key={item.status} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: COLORS[item.status as keyof typeof COLORS],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-medium">{item.count}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{formatCurrency(item.value)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||||
|
|
||||||
|
interface Invoice {
|
||||||
|
id: string;
|
||||||
|
totalAmount: number;
|
||||||
|
issueDate: Date | string;
|
||||||
|
status: string;
|
||||||
|
dueDate: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthlyMetricsChartProps {
|
||||||
|
invoices: Invoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
||||||
|
// Process invoice data to create monthly metrics
|
||||||
|
const monthlyData = invoices.reduce(
|
||||||
|
(acc, invoice) => {
|
||||||
|
const date = new Date(invoice.issueDate);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
acc[monthKey] ??= {
|
||||||
|
month: monthKey,
|
||||||
|
totalInvoices: 0,
|
||||||
|
paidInvoices: 0,
|
||||||
|
pendingInvoices: 0,
|
||||||
|
overdueInvoices: 0,
|
||||||
|
draftInvoices: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
acc[monthKey].totalInvoices += 1;
|
||||||
|
|
||||||
|
switch (effectiveStatus) {
|
||||||
|
case "paid":
|
||||||
|
acc[monthKey].paidInvoices += 1;
|
||||||
|
break;
|
||||||
|
case "sent":
|
||||||
|
acc[monthKey].pendingInvoices += 1;
|
||||||
|
break;
|
||||||
|
case "overdue":
|
||||||
|
acc[monthKey].overdueInvoices += 1;
|
||||||
|
break;
|
||||||
|
case "draft":
|
||||||
|
acc[monthKey].draftInvoices += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
month: string;
|
||||||
|
totalInvoices: number;
|
||||||
|
paidInvoices: number;
|
||||||
|
pendingInvoices: number;
|
||||||
|
overdueInvoices: number;
|
||||||
|
draftInvoices: number;
|
||||||
|
}
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert to array and sort by month
|
||||||
|
const chartData = Object.values(monthlyData)
|
||||||
|
.sort((a, b) => a.month.localeCompare(b.month))
|
||||||
|
.slice(-6) // Show last 6 months
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Animation / motion preferences
|
||||||
|
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||||
|
useAnimationPreferences();
|
||||||
|
const barAnimationDuration = Math.round(
|
||||||
|
500 / (animationSpeedMultiplier || 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const CustomTooltip = ({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{
|
||||||
|
payload: {
|
||||||
|
paidInvoices: number;
|
||||||
|
pendingInvoices: number;
|
||||||
|
overdueInvoices: number;
|
||||||
|
draftInvoices: number;
|
||||||
|
totalInvoices: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
label?: string;
|
||||||
|
}) => {
|
||||||
|
if (active && payload?.length) {
|
||||||
|
const data = payload[0]!.payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||||
|
<p className="font-medium">{label}</p>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
|
||||||
|
<p className="text-primary/80">
|
||||||
|
Pending: {data.pendingInvoices}
|
||||||
|
</p>
|
||||||
|
<p className="text-destructive">
|
||||||
|
Overdue: {data.overdueInvoices}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Draft: {data.draftInvoices}
|
||||||
|
</p>
|
||||||
|
<p className="text-foreground font-medium border-t pt-1">
|
||||||
|
Total: {data.totalInvoices}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No metrics data available
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Monthly metrics will appear here once you create invoices
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="h-48 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="monthLabel"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Bar
|
||||||
|
dataKey="draftInvoices"
|
||||||
|
stackId="a"
|
||||||
|
fill="hsl(0, 0%, 60%)"
|
||||||
|
radius={[0, 0, 0, 0]}
|
||||||
|
isAnimationActive={!prefersReducedMotion}
|
||||||
|
animationDuration={barAnimationDuration}
|
||||||
|
animationEasing="ease-out"
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="paidInvoices"
|
||||||
|
stackId="a"
|
||||||
|
fill="hsl(142, 71%, 45%)"
|
||||||
|
radius={[0, 0, 0, 0]}
|
||||||
|
isAnimationActive={!prefersReducedMotion}
|
||||||
|
animationDuration={barAnimationDuration}
|
||||||
|
animationEasing="ease-out"
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="pendingInvoices"
|
||||||
|
stackId="a"
|
||||||
|
fill="hsl(217, 91%, 60%)"
|
||||||
|
fillOpacity={0.6}
|
||||||
|
radius={[0, 0, 0, 0]}
|
||||||
|
isAnimationActive={!prefersReducedMotion}
|
||||||
|
animationDuration={barAnimationDuration}
|
||||||
|
animationEasing="ease-out"
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="overdueInvoices"
|
||||||
|
stackId="a"
|
||||||
|
fill="hsl(var(--destructive))"
|
||||||
|
radius={[2, 2, 0, 0]}
|
||||||
|
isAnimationActive={!prefersReducedMotion}
|
||||||
|
animationDuration={barAnimationDuration}
|
||||||
|
animationEasing="ease-out"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: "hsl(0, 0%, 60%)" }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">Draft</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: "hsl(142, 71%, 45%)" }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">Paid</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: "hsl(217, 91%, 60%)", opacity: 0.6 }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">Pending</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded-full bg-destructive"
|
||||||
|
/>
|
||||||
|
<span className="text-xs">Overdue</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
interface RevenueChartProps {
|
||||||
|
data: {
|
||||||
|
month: string;
|
||||||
|
revenue: number;
|
||||||
|
monthLabel: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ payload: { revenue: number } }>;
|
||||||
|
label?: string;
|
||||||
|
}) => {
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (active && payload?.length) {
|
||||||
|
const data = payload[0]!.payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
|
||||||
|
<p className="font-medium">{label}</p>
|
||||||
|
<p style={{ color: "hsl(0, 0%, 60%)" }}>
|
||||||
|
Revenue: {formatCurrency(data.revenue)}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{/* Count not available in aggregated view currently */}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RevenueChart({ data }: RevenueChartProps) {
|
||||||
|
// Use data directly
|
||||||
|
const chartData = data;
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||||
|
useAnimationPreferences();
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No revenue data available
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Revenue will appear here once you have paid invoices
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-48 w-full md:h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0.4} />
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor="hsl(217, 91%, 60%)"
|
||||||
|
stopOpacity={0.05}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis
|
||||||
|
dataKey="monthLabel"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
tickFormatter={formatCurrency}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="hsl(217, 91%, 60%)"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#revenueGradient)"
|
||||||
|
isAnimationActive={!prefersReducedMotion}
|
||||||
|
animationDuration={Math.round(
|
||||||
|
600 / (animationSpeedMultiplier ?? 1),
|
||||||
|
)}
|
||||||
|
animationEasing="ease-out"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import {
|
||||||
|
Send,
|
||||||
|
DollarSign,
|
||||||
|
FileText,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Calendar,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
getEffectiveInvoiceStatus,
|
||||||
|
isInvoiceOverdue,
|
||||||
|
getDaysPastDue,
|
||||||
|
getStatusConfig,
|
||||||
|
} from "~/lib/invoice-status";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
|
||||||
|
interface StatusManagerProps {
|
||||||
|
invoiceId: string;
|
||||||
|
currentStatus: StoredInvoiceStatus;
|
||||||
|
dueDate: Date;
|
||||||
|
clientEmail?: string | null;
|
||||||
|
onStatusChange?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusIconConfig = {
|
||||||
|
draft: FileText,
|
||||||
|
sent: Send,
|
||||||
|
paid: CheckCircle,
|
||||||
|
overdue: AlertCircle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusManager({
|
||||||
|
invoiceId,
|
||||||
|
currentStatus,
|
||||||
|
dueDate,
|
||||||
|
clientEmail,
|
||||||
|
onStatusChange,
|
||||||
|
}: StatusManagerProps) {
|
||||||
|
const [isChangingStatus, setIsChangingStatus] = useState(false);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(data.message);
|
||||||
|
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
onStatusChange?.();
|
||||||
|
setIsChangingStatus(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message ?? "Failed to update status");
|
||||||
|
setIsChangingStatus(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendEmail = api.email.sendInvoice.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(data.message);
|
||||||
|
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
onStatusChange?.();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleStatusUpdate = async (newStatus: StoredInvoiceStatus) => {
|
||||||
|
setIsChangingStatus(true);
|
||||||
|
updateStatus.mutate({
|
||||||
|
id: invoiceId,
|
||||||
|
status: newStatus,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendEmail = () => {
|
||||||
|
sendEmail.mutate({ invoiceId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const effectiveStatus = getEffectiveInvoiceStatus(currentStatus, dueDate);
|
||||||
|
const isOverdue = isInvoiceOverdue(currentStatus, dueDate);
|
||||||
|
const daysPastDue = getDaysPastDue(currentStatus, dueDate);
|
||||||
|
const statusConfig = getStatusConfig(currentStatus, dueDate);
|
||||||
|
|
||||||
|
const StatusIcon = statusIconConfig[effectiveStatus];
|
||||||
|
|
||||||
|
const getAvailableActions = () => {
|
||||||
|
const actions = [];
|
||||||
|
|
||||||
|
switch (effectiveStatus) {
|
||||||
|
case "draft":
|
||||||
|
if (clientEmail) {
|
||||||
|
actions.push({
|
||||||
|
key: "send",
|
||||||
|
label: "Send Invoice",
|
||||||
|
action: handleSendEmail,
|
||||||
|
variant: "default" as const,
|
||||||
|
icon: Send,
|
||||||
|
disabled: sendEmail.isPending,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
actions.push({
|
||||||
|
key: "markPaid",
|
||||||
|
label: "Mark as Paid",
|
||||||
|
action: () => handleStatusUpdate("paid"),
|
||||||
|
variant: "secondary" as const,
|
||||||
|
icon: DollarSign,
|
||||||
|
disabled: isChangingStatus,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "sent":
|
||||||
|
actions.push({
|
||||||
|
key: "markPaid",
|
||||||
|
label: "Mark as Paid",
|
||||||
|
action: () => handleStatusUpdate("paid"),
|
||||||
|
variant: "default" as const,
|
||||||
|
icon: DollarSign,
|
||||||
|
disabled: isChangingStatus,
|
||||||
|
});
|
||||||
|
if (clientEmail) {
|
||||||
|
actions.push({
|
||||||
|
key: "resend",
|
||||||
|
label: "Resend Invoice",
|
||||||
|
action: handleSendEmail,
|
||||||
|
variant: "outline" as const,
|
||||||
|
icon: Send,
|
||||||
|
disabled: sendEmail.isPending,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
actions.push({
|
||||||
|
key: "backToDraft",
|
||||||
|
label: "Back to Draft",
|
||||||
|
action: () => handleStatusUpdate("draft"),
|
||||||
|
variant: "outline" as const,
|
||||||
|
icon: FileText,
|
||||||
|
disabled: isChangingStatus,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "overdue":
|
||||||
|
actions.push({
|
||||||
|
key: "markPaid",
|
||||||
|
label: "Mark as Paid",
|
||||||
|
action: () => handleStatusUpdate("paid"),
|
||||||
|
variant: "default" as const,
|
||||||
|
icon: DollarSign,
|
||||||
|
disabled: isChangingStatus,
|
||||||
|
});
|
||||||
|
if (clientEmail) {
|
||||||
|
actions.push({
|
||||||
|
key: "resend",
|
||||||
|
label: "Resend Invoice",
|
||||||
|
action: handleSendEmail,
|
||||||
|
variant: "outline" as const,
|
||||||
|
icon: Send,
|
||||||
|
disabled: sendEmail.isPending,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
actions.push({
|
||||||
|
key: "backToSent",
|
||||||
|
label: "Mark as Sent",
|
||||||
|
action: () => handleStatusUpdate("sent"),
|
||||||
|
variant: "outline" as const,
|
||||||
|
icon: Clock,
|
||||||
|
disabled: isChangingStatus,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "paid":
|
||||||
|
// Paid invoices can be reverted if needed (rare cases)
|
||||||
|
actions.push({
|
||||||
|
key: "revert",
|
||||||
|
label: "Revert to Sent",
|
||||||
|
action: () => handleStatusUpdate("sent"),
|
||||||
|
variant: "outline" as const,
|
||||||
|
icon: RefreshCw,
|
||||||
|
disabled: isChangingStatus,
|
||||||
|
requireConfirmation: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = getAvailableActions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<StatusIcon className="h-5 w-5" />
|
||||||
|
Invoice Status
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Current Status Display */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge className={statusConfig.color} variant="secondary">
|
||||||
|
{statusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{statusConfig.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overdue Warning */}
|
||||||
|
{isOverdue && (
|
||||||
|
<div className="bg-destructive/10 text-destructive flex items-center gap-2 p-3">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{daysPastDue} day{daysPastDue !== 1 ? "s" : ""} overdue
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Due Date Info */}
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
Due:{" "}
|
||||||
|
{new Intl.DateTimeFormat("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(new Date(dueDate))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
{actions.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-foreground text-sm font-medium">
|
||||||
|
Available Actions:
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{actions.map((action) => {
|
||||||
|
const ActionIcon = action.icon;
|
||||||
|
|
||||||
|
if (action.requireConfirmation) {
|
||||||
|
return (
|
||||||
|
<AlertDialog key={action.key}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={action.variant}
|
||||||
|
size="sm"
|
||||||
|
disabled={action.disabled}
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
<ActionIcon className="mr-2 h-4 w-4" />
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Confirm Status Change
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to change this invoice status?
|
||||||
|
This action may affect your records.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={action.action}>
|
||||||
|
Confirm
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={action.key}
|
||||||
|
variant={action.variant}
|
||||||
|
size="sm"
|
||||||
|
onClick={action.action}
|
||||||
|
disabled={action.disabled}
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
{action.disabled &&
|
||||||
|
(action.key === "send" || action.key === "resend") ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : action.disabled &&
|
||||||
|
(action.key === "markPaid" ||
|
||||||
|
action.key === "backToDraft" ||
|
||||||
|
action.key === "backToSent") ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ActionIcon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No Email Warning */}
|
||||||
|
{!clientEmail && effectiveStatus !== "paid" && (
|
||||||
|
<div className="bg-muted text-muted-foreground p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
No email address on file for this client
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs">
|
||||||
|
Add an email address to the client to enable sending invoices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ export default async function BusinessDetailPage({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<div className="space-y-6 pb-32">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={business.name}
|
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
|
||||||
description="View business details and information"
|
description="View business details and information"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
@@ -55,7 +55,7 @@ export default async function BusinessDetailPage({
|
|||||||
<span>Back to Businesses</span>
|
<span>Back to Businesses</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild className="btn-brand-primary shadow-md">
|
<Button asChild variant="default" className="shadow-md">
|
||||||
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
<span>Edit Business</span>
|
<span>Edit Business</span>
|
||||||
@@ -66,11 +66,11 @@ export default async function BusinessDetailPage({
|
|||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* Business Information Card */}
|
{/* Business Information Card */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card className="card-primary">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="bg-blue-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Building className="text-icon-blue h-5 w-5" />
|
<Building className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span>Business Information</span>
|
<span>Business Information</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -84,8 +84,8 @@ export default async function BusinessDetailPage({
|
|||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{business.email && (
|
{business.email && (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-green-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Mail className="text-icon-green h-4 w-4" />
|
<Mail className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -100,8 +100,8 @@ export default async function BusinessDetailPage({
|
|||||||
|
|
||||||
{business.phone && (
|
{business.phone && (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-green-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Phone className="text-icon-green h-4 w-4" />
|
<Phone className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -116,8 +116,8 @@ export default async function BusinessDetailPage({
|
|||||||
|
|
||||||
{business.website && (
|
{business.website && (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-green-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Globe className="text-icon-green h-4 w-4" />
|
<Globe className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -137,8 +137,8 @@ export default async function BusinessDetailPage({
|
|||||||
|
|
||||||
{business.taxId && (
|
{business.taxId && (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-green-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Hash className="text-icon-green h-4 w-4" />
|
<Hash className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -162,8 +162,8 @@ export default async function BusinessDetailPage({
|
|||||||
Business Address
|
Business Address
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<div className="bg-green-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<MapPin className="text-icon-green h-4 w-4" />
|
<MapPin className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
{business.addressLine1 && (
|
{business.addressLine1 && (
|
||||||
@@ -205,8 +205,8 @@ export default async function BusinessDetailPage({
|
|||||||
<h3 className="mb-4 text-lg font-semibold">Business Details</h3>
|
<h3 className="mb-4 text-lg font-semibold">Business Details</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-green-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Calendar className="text-icon-green h-4 w-4" />
|
<Calendar className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -218,11 +218,32 @@ export default async function BusinessDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{business.nickname && (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="bg-primary/10 p-2">
|
||||||
|
<Building className="text-primary h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Nickname
|
||||||
|
</p>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Internal only
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-foreground text-sm">
|
||||||
|
{business.nickname}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Default Business Badge */}
|
{/* Default Business Badge */}
|
||||||
{business.isDefault && (
|
{business.isDefault && (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-green-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Building className="text-icon-green h-4 w-4" />
|
<Building className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -230,7 +251,7 @@ export default async function BusinessDetailPage({
|
|||||||
</p>
|
</p>
|
||||||
<Badge
|
<Badge
|
||||||
variant="default"
|
variant="default"
|
||||||
className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
className="bg-primary/10 text-primary"
|
||||||
>
|
>
|
||||||
Default Business
|
Default Business
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -245,11 +266,11 @@ export default async function BusinessDetailPage({
|
|||||||
|
|
||||||
{/* Settings & Actions Card */}
|
{/* Settings & Actions Card */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="card-primary">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="bg-blue-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Building className="text-icon-blue h-5 w-5" />
|
<Building className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span>Quick Actions</span>
|
<span>Quick Actions</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -281,7 +302,7 @@ export default async function BusinessDetailPage({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Information Card */}
|
{/* Information Card */}
|
||||||
<Card className="card-primary">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">About This Business</CardTitle>
|
<CardTitle className="text-lg">About This Business</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -292,7 +313,7 @@ export default async function BusinessDetailPage({
|
|||||||
represents your company information to clients.
|
represents your company information to clients.
|
||||||
</p>
|
</p>
|
||||||
{business.isDefault && (
|
{business.isDefault && (
|
||||||
<p className="text-green-600 dark:text-green-400">
|
<p className="text-primary">
|
||||||
This is your default business and will be automatically
|
This is your default business and will be automatically
|
||||||
selected when creating new invoices.
|
selected when creating new invoices.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { toast } from "sonner";
|
|||||||
interface Business {
|
interface Business {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
nickname: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
addressLine1: string | null;
|
addressLine1: string | null;
|
||||||
@@ -42,17 +43,6 @@ interface BusinessesDataTableProps {
|
|||||||
businesses: Business[];
|
businesses: Business[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAddress = (business: Business) => {
|
|
||||||
const parts = [
|
|
||||||
business.addressLine1,
|
|
||||||
business.addressLine2,
|
|
||||||
business.city,
|
|
||||||
business.state,
|
|
||||||
business.postalCode,
|
|
||||||
].filter(Boolean);
|
|
||||||
return parts.join(", ") || "—";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [businessToDelete, setBusinessToDelete] = useState<Business | null>(
|
const [businessToDelete, setBusinessToDelete] = useState<Business | null>(
|
||||||
@@ -61,6 +51,11 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const searchableBusinesses = businesses.map((b) => ({
|
||||||
|
...b,
|
||||||
|
searchValue: `${b.name} ${b.nickname ?? ""}`.trim(),
|
||||||
|
}));
|
||||||
|
|
||||||
const deleteBusinessMutation = api.businesses.delete.useMutation({
|
const deleteBusinessMutation = api.businesses.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Business deleted successfully");
|
toast.success("Business deleted successfully");
|
||||||
@@ -91,19 +86,30 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
const business = row.original;
|
const business = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-blue-subtle hidden rounded-lg p-2 sm:flex">
|
<div className="bg-primary/10 hidden p-2 sm:flex">
|
||||||
<Building className="text-icon-blue h-4 w-4" />
|
<Building className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium">{business.name}</p>
|
<p className="truncate font-medium">{business.name}</p>
|
||||||
<p className="text-muted-foreground truncate text-sm">
|
<p className="text-muted-foreground truncate text-sm">
|
||||||
{business.email ?? "—"}
|
{business.nickname ?? "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "email",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Email" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => row.original.email ?? "—",
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden sm:table-cell",
|
||||||
|
cellClassName: "hidden sm:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "phone",
|
accessorKey: "phone",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@@ -115,26 +121,6 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
cellClassName: "hidden md:table-cell",
|
cellClassName: "hidden md:table-cell",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "address",
|
|
||||||
header: "Address",
|
|
||||||
cell: ({ row }) => formatAddress(row.original),
|
|
||||||
meta: {
|
|
||||||
headerClassName: "hidden lg:table-cell",
|
|
||||||
cellClassName: "hidden lg:table-cell",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "taxId",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader column={column} title="Tax ID" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => row.original.taxId ?? "—",
|
|
||||||
meta: {
|
|
||||||
headerClassName: "hidden xl:table-cell",
|
|
||||||
cellClassName: "hidden xl:table-cell",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: "website",
|
accessorKey: "website",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@@ -175,6 +161,15 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "searchValue",
|
||||||
|
header: "Search",
|
||||||
|
cell: () => null,
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden",
|
||||||
|
cellClassName: "hidden",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -210,9 +205,9 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={businesses}
|
data={searchableBusinesses}
|
||||||
searchKey="name"
|
searchKey="searchValue"
|
||||||
searchPlaceholder="Search businesses..."
|
searchPlaceholder="Search by name or nickname..."
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -226,8 +221,8 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
<DialogTitle>Are you sure?</DialogTitle>
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
business "{businessToDelete?.name}" and remove all associated
|
business "{businessToDelete?.name}" and remove all
|
||||||
data.
|
associated data.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { BusinessForm } from "~/components/forms/business-form";
|
import { BusinessForm } from "~/components/forms/business-form";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
import { HydrateClient } from "~/trpc/server";
|
import { HydrateClient } from "~/trpc/server";
|
||||||
|
|
||||||
export default function NewBusinessPage() {
|
export default function NewBusinessPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
|
||||||
<PageHeader
|
|
||||||
title="Add Business"
|
|
||||||
description="Enter business details below to add a new business."
|
|
||||||
variant="gradient"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<BusinessForm mode="create" />
|
<BusinessForm mode="create" />
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
|
import { Plus } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { api, HydrateClient } from "~/trpc/server";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Plus, Building } from "lucide-react";
|
|
||||||
import { BusinessesDataTable } from "./_components/businesses-data-table";
|
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { api, HydrateClient } from "~/trpc/server";
|
||||||
|
import { BusinessesDataTable } from "./_components/businesses-data-table";
|
||||||
|
|
||||||
// Businesses Table Component
|
// Businesses Table Component
|
||||||
async function BusinessesTable() {
|
async function BusinessesTable() {
|
||||||
@@ -16,13 +16,13 @@ async function BusinessesTable() {
|
|||||||
|
|
||||||
export default async function BusinessesPage() {
|
export default async function BusinessesPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="page-enter space-y-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Businesses"
|
title="Businesses"
|
||||||
description="Manage your businesses and their information"
|
description="Manage your businesses and their information"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<Button asChild className="btn-brand-primary shadow-md">
|
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||||
<Link href="/dashboard/businesses/new">
|
<Link href="/dashboard/businesses/new">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
<span>Add Business</span>
|
<span>Add Business</span>
|
||||||
@@ -31,10 +31,10 @@ export default async function BusinessesPage() {
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<Suspense fallback={<DataTableSkeleton columns={6} rows={5} />}>
|
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
||||||
<BusinessesTable />
|
<BusinessesTable />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
|
||||||
interface ClientDetailPageProps {
|
interface ClientDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -67,7 +69,7 @@ export default async function ClientDetailPage({
|
|||||||
<span>Back to Clients</span>
|
<span>Back to Clients</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild className="btn-brand-primary shadow-md">
|
<Button asChild variant="default" className="shadow-md">
|
||||||
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
<span>Edit Client</span>
|
<span>Edit Client</span>
|
||||||
@@ -78,11 +80,11 @@ export default async function ClientDetailPage({
|
|||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* Client Information Card */}
|
{/* Client Information Card */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card className="card-primary">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="bg-blue-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Building className="text-icon-blue h-5 w-5" />
|
<Building className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span>Contact Information</span>
|
<span>Contact Information</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -92,8 +94,8 @@ export default async function ClientDetailPage({
|
|||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{client.email && (
|
{client.email && (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-green-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Mail className="text-icon-green h-4 w-4" />
|
<Mail className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -106,8 +108,8 @@ export default async function ClientDetailPage({
|
|||||||
|
|
||||||
{client.phone && (
|
{client.phone && (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-green-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Phone className="text-icon-green h-4 w-4" />
|
<Phone className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -124,8 +126,8 @@ export default async function ClientDetailPage({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="mb-4 text-lg font-semibold">Client Address</h3>
|
<h3 className="mb-4 text-lg font-semibold">Client Address</h3>
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<div className="bg-green-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<MapPin className="text-icon-green h-4 w-4" />
|
<MapPin className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
{client.addressLine1 && (
|
{client.addressLine1 && (
|
||||||
@@ -153,8 +155,8 @@ export default async function ClientDetailPage({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="mb-4 text-lg font-semibold">Client Details</h3>
|
<h3 className="mb-4 text-lg font-semibold">Client Details</h3>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-green-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Calendar className="text-icon-green h-4 w-4" />
|
<Calendar className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -172,11 +174,11 @@ export default async function ClientDetailPage({
|
|||||||
|
|
||||||
{/* Stats Card */}
|
{/* Stats Card */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="card-primary">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="bg-blue-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<DollarSign className="text-icon-blue h-5 w-5" />
|
<DollarSign className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span>Invoice Summary</span>
|
<span>Invoice Summary</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -211,8 +213,8 @@ export default async function ClientDetailPage({
|
|||||||
<Card className="">
|
<Card className="">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="bg-blue-subtle rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<DollarSign className="text-icon-blue h-5 w-5" />
|
<DollarSign className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span>Recent Invoices</span>
|
<span>Recent Invoices</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -222,34 +224,50 @@ export default async function ClientDetailPage({
|
|||||||
{client.invoices.slice(0, 3).map((invoice) => (
|
{client.invoices.slice(0, 3).map((invoice) => (
|
||||||
<div
|
<div
|
||||||
key={invoice.id}
|
key={invoice.id}
|
||||||
className="card-secondary transition-colors hover:bg-gray-200/70 dark:hover:bg-gray-700/60 flex items-center justify-between rounded-lg border p-3"
|
className="card-secondary hover:bg-muted/50 border p-3 transition-colors"
|
||||||
>
|
>
|
||||||
<div>
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="text-foreground font-medium">
|
<div className="min-w-0">
|
||||||
|
<p className="text-foreground font-medium break-words">
|
||||||
{invoice.invoiceNumber}
|
{invoice.invoiceNumber}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{formatDate(invoice.issueDate)}
|
{formatDate(invoice.issueDate)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="flex flex-shrink-0 items-center gap-2 self-start sm:flex-col sm:items-end sm:gap-1">
|
||||||
<p className="text-foreground font-semibold">
|
<p className="text-foreground font-semibold">
|
||||||
{formatCurrency(invoice.totalAmount)}
|
{formatCurrency(invoice.totalAmount)}
|
||||||
</p>
|
</p>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
invoice.status === "paid"
|
getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
) === "paid"
|
||||||
? "default"
|
? "default"
|
||||||
: invoice.status === "sent"
|
: getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
) === "sent"
|
||||||
? "secondary"
|
? "secondary"
|
||||||
|
: getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
) === "overdue"
|
||||||
|
? "destructive"
|
||||||
: "outline"
|
: "outline"
|
||||||
}
|
}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{invoice.status}
|
{getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -90,13 +90,13 @@ export function ClientsDataTable({
|
|||||||
const client = row.original;
|
const client = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-status-info-muted hidden rounded-lg p-2 sm:flex">
|
<div className="bg-primary/10 hidden p-2 sm:flex">
|
||||||
<UserPlus className="text-status-info h-4 w-4" />
|
<UserPlus className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium">{client.name}</p>
|
<p className="truncate font-medium">{client.name}</p>
|
||||||
<p className="text-muted-foreground truncate text-sm">
|
<p className="text-muted-foreground truncate text-sm">
|
||||||
{client.email || "—"}
|
{client.email ?? "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +108,7 @@ export function ClientsDataTable({
|
|||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Phone" />
|
<DataTableColumnHeader column={column} title="Phone" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => row.original.phone || "—",
|
cell: ({ row }) => row.original.phone ?? "—",
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "hidden md:table-cell",
|
headerClassName: "hidden md:table-cell",
|
||||||
cellClassName: "hidden md:table-cell",
|
cellClassName: "hidden md:table-cell",
|
||||||
@@ -192,7 +192,8 @@ export function ClientsDataTable({
|
|||||||
<DialogTitle>Are you sure?</DialogTitle>
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
client "{clientToDelete?.name}" and remove all associated data.
|
client "{clientToDelete?.name}" and remove all
|
||||||
|
associated data.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { HydrateClient } from "~/trpc/server";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { ClientsTable } from "./_components/clients-table";
|
import Link from "next/link";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
import { PageContent, PageSection } from "~/components/layout/page-layout";
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { HydrateClient } from "~/trpc/server";
|
||||||
|
import { ClientsTable } from "./_components/clients-table";
|
||||||
|
|
||||||
export default async function ClientsPage() {
|
export default async function ClientsPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="page-enter space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Clients"
|
title="Clients"
|
||||||
description="Manage your clients and their information."
|
description="Manage your clients and their information."
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<Button asChild className="btn-brand-primary shadow-md">
|
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||||
<Link href="/dashboard/clients/new">
|
<Link href="/dashboard/clients/new">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
<span>Add Client</span>
|
<span>Add Client</span>
|
||||||
@@ -25,6 +24,6 @@ export default async function ClientsPage() {
|
|||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<ClientsTable />
|
<ClientsTable />
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
|
||||||
|
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||||
|
|
||||||
|
interface ExpenseFormData {
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
category: string;
|
||||||
|
billable: boolean;
|
||||||
|
reimbursable: boolean;
|
||||||
|
taxDeductible: boolean;
|
||||||
|
notes: string;
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultForm: ExpenseFormData = {
|
||||||
|
date: new Date(),
|
||||||
|
description: "",
|
||||||
|
amount: 0,
|
||||||
|
currency: "USD",
|
||||||
|
category: "",
|
||||||
|
billable: false,
|
||||||
|
reimbursable: false,
|
||||||
|
taxDeductible: false,
|
||||||
|
notes: "",
|
||||||
|
clientId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ExpensesPage() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data: expenses = [], isLoading } = api.expenses.getAll.useQuery();
|
||||||
|
const { data: clients = [] } = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
const create = api.expenses.create.useMutation({
|
||||||
|
onSuccess: () => { toast.success("Expense added"); void utils.expenses.getAll.invalidate(); setOpen(false); setForm(defaultForm); },
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
const update = api.expenses.update.useMutation({
|
||||||
|
onSuccess: () => { toast.success("Expense updated"); void utils.expenses.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); },
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
const del = api.expenses.delete.useMutation({
|
||||||
|
onSuccess: () => { toast.success("Expense deleted"); void utils.expenses.getAll.invalidate(); setDeleteId(null); },
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpen = () => { setEditId(null); setForm(defaultForm); setOpen(true); };
|
||||||
|
const handleEdit = (expense: typeof expenses[0]) => {
|
||||||
|
setEditId(expense.id);
|
||||||
|
setForm({
|
||||||
|
date: new Date(expense.date),
|
||||||
|
description: expense.description,
|
||||||
|
amount: expense.amount,
|
||||||
|
currency: expense.currency,
|
||||||
|
category: expense.category ?? "",
|
||||||
|
billable: expense.billable,
|
||||||
|
reimbursable: expense.reimbursable,
|
||||||
|
taxDeductible: expense.taxDeductible ?? false,
|
||||||
|
notes: expense.notes ?? "",
|
||||||
|
clientId: expense.clientId ?? "",
|
||||||
|
});
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!form.description.trim()) { toast.error("Description is required"); return; }
|
||||||
|
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; }
|
||||||
|
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined, taxDeductible: form.taxDeductible };
|
||||||
|
if (editId) update.mutate({ id: editId, ...payload });
|
||||||
|
else create.mutate(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
||||||
|
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0);
|
||||||
|
const deductibleTotal = expenses.filter((e) => e.taxDeductible).reduce((s, e) => s + e.amount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-enter space-y-6 pb-6">
|
||||||
|
<PageHeader title="Expenses" description="Track billable and non-billable expenses" variant="gradient">
|
||||||
|
<Button onClick={handleOpen} variant="default" className="hover-lift shadow-md">
|
||||||
|
<Plus className="mr-2 h-5 w-5" /> Add Expense
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{formatCurrency(totalExpenses)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Billable</p>
|
||||||
|
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Deductible</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-green-600">{formatCurrency(deductibleTotal)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expenses list */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Receipt className="h-5 w-5" /> All Expenses
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-6 text-center text-sm text-muted-foreground">Loading…</div>
|
||||||
|
) : expenses.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
||||||
|
<p className="text-muted-foreground text-sm">No expenses yet. Add your first expense.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{expenses.map((expense) => (
|
||||||
|
<div key={expense.id} className="flex items-start justify-between gap-3 p-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="font-medium">{expense.description}</p>
|
||||||
|
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
|
||||||
|
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>}
|
||||||
|
{expense.taxDeductible && <Badge variant="outline" className="text-xs text-green-600 border-green-300">Tax Deductible</Badge>}
|
||||||
|
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
{new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(expense.date))}
|
||||||
|
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||||
|
</p>
|
||||||
|
{expense.notes && <p className="text-muted-foreground mt-1 text-xs">{expense.notes}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
<p className="font-semibold">{formatCurrency(expense.amount, expense.currency)}</p>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(expense)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(expense.id)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Add/Edit dialog */}
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description *</Label>
|
||||||
|
<Input value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} placeholder="e.g. Laptop charger" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Amount *</Label>
|
||||||
|
<NumberInput value={form.amount} onChange={(v) => setForm((p) => ({ ...p, amount: v }))} min={0} step={0.01} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Currency</Label>
|
||||||
|
<Select value={form.currency} onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{SUPPORTED_CURRENCIES.map((c) => <SelectItem key={c.code} value={c.code}>{c.code}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Date</Label>
|
||||||
|
<DatePicker date={form.date} onDateChange={(d) => setForm((p) => ({ ...p, date: d ?? new Date() }))} className="w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category</Label>
|
||||||
|
<Select value={form.category || "none"} onValueChange={(v) => setForm((p) => ({ ...p, category: v === "none" ? "" : v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{EXPENSE_CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Client (optional)</Label>
|
||||||
|
<Select value={form.clientId || "none"} onValueChange={(v) => setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="No client" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">No client</SelectItem>
|
||||||
|
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
|
||||||
|
<span className="text-sm">Billable</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
|
||||||
|
<span className="text-sm">Reimbursable</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox checked={form.taxDeductible} onCheckedChange={(v) => setForm((p) => ({ ...p, taxDeductible: !!v }))} />
|
||||||
|
<span className="text-sm">Tax Deductible</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Notes (optional)</Label>
|
||||||
|
<Input value={form.notes} onChange={(e) => setForm((p) => ({ ...p, notes: e.target.value }))} placeholder="Additional details…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
|
||||||
|
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Add Expense"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete dialog */}
|
||||||
|
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Expense</DialogTitle>
|
||||||
|
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}>
|
||||||
|
{del.isPending ? "Deleting…" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,44 +1,45 @@
|
|||||||
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
|
||||||
export function InvoiceDetailsSkeleton() {
|
export function InvoiceDetailsSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-24">
|
<div className="space-y-6 pb-24">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Loading..."
|
||||||
<Skeleton className="bg-muted/30 h-8 w-48 sm:h-9 sm:w-64" />
|
description="View and manage invoice information"
|
||||||
<Skeleton className="bg-muted/30 mt-1 h-4 w-40 sm:w-48" />
|
variant="gradient"
|
||||||
</div>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<Skeleton className="h-10 w-10 sm:w-32" />
|
||||||
<Skeleton className="bg-muted/30 h-8 w-20 sm:h-9 sm:w-24" />
|
<Skeleton className="h-10 w-24" />
|
||||||
<Skeleton className="bg-muted/30 h-8 w-16 sm:h-9 sm:w-20" />
|
</PageHeader>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
{/* Invoice Header Skeleton */}
|
{/* Invoice Header Skeleton */}
|
||||||
<Card className="card-primary">
|
<Card>
|
||||||
<CardContent className="p-4 sm:p-6">
|
<CardContent className="p-4 sm:p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-start justify-between gap-6">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
<Skeleton className="bg-muted/30 h-6 w-40 sm:h-8 sm:w-48" />
|
<Skeleton className="h-8 w-48" />
|
||||||
<Skeleton className="bg-muted/30 h-5 w-16 sm:h-6" />
|
<Skeleton className="h-6 w-24 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 sm:space-y-0">
|
<div className="space-y-1 text-sm sm:space-y-0">
|
||||||
<Skeleton className="bg-muted/30 h-3 w-32 sm:h-4 sm:w-40" />
|
<div className="flex gap-2">
|
||||||
<Skeleton className="bg-muted/30 h-3 w-28 sm:hidden sm:h-4 sm:w-36" />
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="hidden h-4 w-32 sm:block" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-right">
|
</div>
|
||||||
<Skeleton className="bg-muted/30 h-3 w-20 sm:h-4" />
|
<div className="flex-shrink-0 text-left sm:text-right">
|
||||||
<Skeleton className="bg-muted/30 mt-1 h-6 w-24 sm:h-8 sm:w-28" />
|
<Skeleton className="mb-1 h-4 w-24 sm:ml-auto" />
|
||||||
|
<Skeleton className="h-9 w-32 sm:ml-auto" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,87 +48,107 @@ export function InvoiceDetailsSkeleton() {
|
|||||||
|
|
||||||
{/* Client & Business Info */}
|
{/* Client & Business Info */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{Array.from({ length: 2 }).map((_, i) => (
|
{/* Client Skeleton */}
|
||||||
<Card key={i} className="card-primary">
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Skeleton className="bg-muted/30 h-4 w-4 sm:h-5 sm:w-5" />
|
<Skeleton className="h-5 w-5 rounded-full" />
|
||||||
<Skeleton className="bg-muted/30 h-5 w-16 sm:h-6" />
|
<Skeleton className="h-5 w-16" />
|
||||||
</div>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<Skeleton className="bg-muted/30 h-5 w-32 sm:h-6" />
|
<Skeleton className="h-7 w-48" />
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{Array.from({ length: 3 }).map((_, j) => (
|
<div className="flex items-center gap-3">
|
||||||
<div key={j} className="flex items-center gap-3">
|
<Skeleton className="h-8 w-8 rounded-md" />
|
||||||
<Skeleton className="bg-muted/30 h-8 w-8 rounded-lg" />
|
<Skeleton className="h-4 w-40" />
|
||||||
<Skeleton className="bg-muted/30 h-4 w-28" />
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-md" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-md" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Business Skeleton */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-5 w-5 rounded-full" />
|
||||||
|
<Skeleton className="h-5 w-16" />
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Skeleton className="h-7 w-48" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-md" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-md" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invoice Items Skeleton */}
|
{/* Invoice Items Skeleton */}
|
||||||
<Card className="card-primary">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Skeleton className="bg-muted/30 h-4 w-4 sm:h-5 sm:w-5" />
|
<Skeleton className="h-5 w-5 rounded-full" />
|
||||||
<Skeleton className="bg-muted/30 h-5 w-28 sm:h-6" />
|
<Skeleton className="h-5 w-32" />
|
||||||
</div>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{/* Item Rows */}
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<div key={i} className="space-y-3 rounded-lg border p-4">
|
<Card key={i} className="bg-secondary/50 border-0">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<CardContent className="p-3">
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Skeleton className="bg-muted/30 mb-2 h-4 w-full sm:h-5 sm:w-3/4" />
|
|
||||||
<div className="space-y-1 sm:space-y-0">
|
|
||||||
<Skeleton className="bg-muted/30 h-3 w-20 sm:h-4 sm:w-24" />
|
|
||||||
<Skeleton className="bg-muted/30 h-3 w-16 sm:hidden sm:h-4 sm:w-20" />
|
|
||||||
<Skeleton className="bg-muted/30 h-3 w-24 sm:hidden sm:h-4 sm:w-28" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-shrink-0 text-right">
|
|
||||||
<Skeleton className="bg-muted/30 h-4 w-16 sm:h-5 sm:w-20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Totals */}
|
|
||||||
<div className="bg-muted/30 rounded-lg p-4">
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<Skeleton className="bg-muted/30 h-4 w-16" />
|
<div className="min-w-0 flex-1">
|
||||||
<Skeleton className="bg-muted/30 h-4 w-20" />
|
<Skeleton className="mb-2 h-5 w-3/4" />
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
|
||||||
<Skeleton className="bg-muted/30 h-4 w-20" />
|
|
||||||
<Skeleton className="bg-muted/30 h-4 w-16" />
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Skeleton className="bg-muted/30 h-5 w-12" />
|
|
||||||
<Skeleton className="bg-muted/30 h-5 w-24" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<Skeleton className="h-6 w-24" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Totals */}
|
||||||
<Card className="card-primary">
|
<div className="bg-secondary rounded-lg p-4">
|
||||||
<CardHeader>
|
<div className="space-y-3">
|
||||||
<Skeleton className="bg-muted/30 h-6 w-16" />
|
<div className="flex justify-between">
|
||||||
</CardHeader>
|
<Skeleton className="h-4 w-20" />
|
||||||
<CardContent>
|
<Skeleton className="h-4 w-24" />
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<Skeleton className="bg-muted/30 h-4 w-full" />
|
<div className="flex justify-between">
|
||||||
<Skeleton className="bg-muted/30 h-4 w-3/4" />
|
<Skeleton className="h-4 w-24" />
|
||||||
<Skeleton className="bg-muted/30 h-4 w-1/2" />
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Skeleton className="h-6 w-16" />
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -135,17 +156,18 @@ export function InvoiceDetailsSkeleton() {
|
|||||||
|
|
||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="card-primary sticky top-6">
|
<Card className="lg:sticky lg:top-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Skeleton className="bg-muted/30 h-5 w-5" />
|
<Skeleton className="h-5 w-5 rounded-full" />
|
||||||
<Skeleton className="bg-muted/30 h-6 w-16" />
|
<Skeleton className="h-5 w-24" />
|
||||||
</div>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
<Skeleton className="h-10 w-full" />
|
||||||
<Skeleton key={i} className="bg-muted/30 h-10 w-full" />
|
<Skeleton className="h-10 w-full" />
|
||||||
))}
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,13 +40,32 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
accessorKey: "date",
|
accessorKey: "date",
|
||||||
header: "Date",
|
header: "Date",
|
||||||
cell: ({ row }) => formatDate(row.getValue("date")),
|
cell: ({ row }) => formatDate(row.getValue("date")),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden sm:table-cell",
|
||||||
|
cellClassName: "hidden sm:table-cell",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "description",
|
accessorKey: "description",
|
||||||
header: "Description",
|
header: "Description",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<div className="font-medium">{row.getValue("description")}</div>
|
const item = row.original;
|
||||||
),
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop: plain description */}
|
||||||
|
<div className="hidden font-medium sm:block">
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
{/* Mobile: description + date + hours @ rate stacked */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<p className="font-medium">{item.description}</p>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
{formatDate(item.date)} · {item.hours}h @ {formatCurrency(item.rate)}/hr
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "hours",
|
accessorKey: "hours",
|
||||||
@@ -54,6 +73,10 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-right">{row.getValue("hours")}</div>
|
<div className="text-right">{row.getValue("hours")}</div>
|
||||||
),
|
),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden sm:table-cell",
|
||||||
|
cellClassName: "hidden sm:table-cell",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "rate",
|
accessorKey: "rate",
|
||||||
@@ -61,12 +84,16 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
|
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
|
||||||
),
|
),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden sm:table-cell",
|
||||||
|
cellClassName: "hidden sm:table-cell",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "amount",
|
accessorKey: "amount",
|
||||||
header: "Amount",
|
header: "Amount",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-icon-emerald text-right font-medium">
|
<div className="text-primary text-right font-medium">
|
||||||
{formatCurrency(row.getValue("amount"))}
|
{formatCurrency(row.getValue("amount"))}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Download, Loader2 } from "lucide-react";
|
|||||||
|
|
||||||
interface PDFDownloadButtonProps {
|
interface PDFDownloadButtonProps {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
variant?: "default" | "outline" | "ghost" | "icon";
|
variant?: "default" | "outline" | "ghost" | "icon" | "secondary";
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +25,9 @@ export function PDFDownloadButton({
|
|||||||
{ id: invoiceId },
|
{ id: invoiceId },
|
||||||
{ enabled: false },
|
{ enabled: false },
|
||||||
);
|
);
|
||||||
|
const { data: platformTheme } = api.settings.getTheme.useQuery(undefined, {
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
const handleDownloadPDF = async () => {
|
const handleDownloadPDF = async () => {
|
||||||
if (isGenerating) return;
|
if (isGenerating) return;
|
||||||
@@ -39,7 +42,29 @@ export function PDFDownloadButton({
|
|||||||
throw new Error("Invoice not found");
|
throw new Error("Invoice not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
await generateInvoicePDF(invoiceData);
|
// Map invoice to PDF format with currency support
|
||||||
|
const pdfData = {
|
||||||
|
invoiceNumber: invoiceData.invoiceNumber,
|
||||||
|
invoicePrefix: invoiceData.invoicePrefix,
|
||||||
|
issueDate: new Date(invoiceData.issueDate),
|
||||||
|
dueDate: new Date(invoiceData.dueDate),
|
||||||
|
status: invoiceData.status,
|
||||||
|
totalAmount: invoiceData.totalAmount,
|
||||||
|
taxRate: invoiceData.taxRate,
|
||||||
|
currency: invoiceData.currency ?? "USD",
|
||||||
|
notes: invoiceData.notes,
|
||||||
|
business: invoiceData.business,
|
||||||
|
client: invoiceData.client,
|
||||||
|
items: invoiceData.items,
|
||||||
|
};
|
||||||
|
|
||||||
|
await generateInvoicePDF(pdfData, {
|
||||||
|
pdfTemplate: platformTheme?.pdfTemplate,
|
||||||
|
pdfAccentColor: platformTheme?.pdfAccentColor,
|
||||||
|
pdfFooterText: platformTheme?.pdfFooterText,
|
||||||
|
pdfShowLogo: platformTheme?.pdfShowLogo,
|
||||||
|
pdfShowPageNumbers: platformTheme?.pdfShowPageNumbers,
|
||||||
|
});
|
||||||
toast.success("PDF downloaded successfully");
|
toast.success("PDF downloaded successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("PDF generation error:", error);
|
console.error("PDF generation error:", error);
|
||||||
@@ -77,12 +102,12 @@ export function PDFDownloadButton({
|
|||||||
>
|
>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
<span>Generating PDF...</span>
|
<span>Generating PDF...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Download className="h-5 w-5" />
|
<Download className="mr-2 h-5 w-5" />
|
||||||
<span>Download PDF</span>
|
<span>Download PDF</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
|
||||||
import { Send, Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
interface SendInvoiceButtonProps {
|
|
||||||
invoiceId: string;
|
|
||||||
variant?: "default" | "outline" | "ghost" | "icon";
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SendInvoiceButton({
|
|
||||||
invoiceId,
|
|
||||||
variant = "outline",
|
|
||||||
className,
|
|
||||||
}: SendInvoiceButtonProps) {
|
|
||||||
const [isSending, setIsSending] = useState(false);
|
|
||||||
|
|
||||||
// Fetch invoice data when sending is triggered
|
|
||||||
const { refetch: fetchInvoice } = api.invoices.getById.useQuery(
|
|
||||||
{ id: invoiceId },
|
|
||||||
{ enabled: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSendInvoice = async () => {
|
|
||||||
if (isSending) return;
|
|
||||||
|
|
||||||
setIsSending(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch fresh invoice data
|
|
||||||
const { data: invoice } = await fetchInvoice();
|
|
||||||
|
|
||||||
if (!invoice) {
|
|
||||||
throw new Error("Invoice not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate PDF blob for potential attachment
|
|
||||||
const pdfBlob = await generateInvoicePDFBlob(invoice);
|
|
||||||
|
|
||||||
// Create a temporary download URL for the PDF
|
|
||||||
const pdfUrl = URL.createObjectURL(pdfBlob);
|
|
||||||
|
|
||||||
// Format currency
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format date
|
|
||||||
const formatDate = (date: Date) => {
|
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
}).format(new Date(date));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate days until due
|
|
||||||
const today = new Date();
|
|
||||||
const dueDate = new Date(invoice.dueDate);
|
|
||||||
const daysUntilDue = Math.ceil(
|
|
||||||
(dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create professional email template
|
|
||||||
const subject = `Invoice ${invoice.invoiceNumber} - ${formatCurrency(invoice.totalAmount)}`;
|
|
||||||
|
|
||||||
const body = `Dear ${invoice.client.name},
|
|
||||||
|
|
||||||
I hope this email finds you well. Please find attached invoice ${invoice.invoiceNumber} for the services provided.
|
|
||||||
|
|
||||||
Invoice Details:
|
|
||||||
• Invoice Number: ${invoice.invoiceNumber}
|
|
||||||
• Issue Date: ${formatDate(invoice.issueDate)}
|
|
||||||
• Due Date: ${formatDate(invoice.dueDate)}
|
|
||||||
• Amount Due: ${formatCurrency(invoice.totalAmount)}
|
|
||||||
${daysUntilDue > 0 ? `• Payment Due: In ${daysUntilDue} days` : daysUntilDue === 0 ? `• Payment Due: Today` : `• Status: ${Math.abs(daysUntilDue)} days overdue`}
|
|
||||||
|
|
||||||
${invoice.notes ? `\nAdditional Notes:\n${invoice.notes}\n` : ""}
|
|
||||||
Please review the attached invoice and remit payment by the due date. If you have any questions or concerns regarding this invoice, please don't hesitate to contact me.
|
|
||||||
|
|
||||||
Thank you for your business!
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
${invoice.business?.name ?? "Your Business Name"}
|
|
||||||
${invoice.business?.email ? `\n${invoice.business.email}` : ""}
|
|
||||||
${invoice.business?.phone ? `\n${invoice.business.phone}` : ""}`;
|
|
||||||
|
|
||||||
// Create mailto link
|
|
||||||
const mailtoLink = `mailto:${invoice.client.email ?? ""}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
||||||
|
|
||||||
// Create a temporary link element to trigger mailto
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = mailtoLink;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
|
|
||||||
// Clean up the PDF URL object
|
|
||||||
URL.revokeObjectURL(pdfUrl);
|
|
||||||
|
|
||||||
toast.success("Email client opened with invoice details");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Send invoice error:", error);
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to prepare invoice email",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (variant === "icon") {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={handleSendInvoice}
|
|
||||||
disabled={isSending}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{isSending ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
|
|
||||||
) : (
|
|
||||||
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={handleSendInvoice}
|
|
||||||
disabled={isSending}
|
|
||||||
variant={variant}
|
|
||||||
size="default"
|
|
||||||
className={`w-full shadow-sm ${className}`}
|
|
||||||
data-testid="send-invoice-button"
|
|
||||||
>
|
|
||||||
{isSending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
<span>Preparing Email...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Send className="mr-2 h-4 w-4" />
|
|
||||||
<span>Send Invoice</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { InvoiceView } from "~/components/data/invoice-view";
|
|
||||||
import { InvoiceForm } from "~/components/forms/invoice-form";
|
|
||||||
|
|
||||||
interface UnifiedInvoicePageProps {
|
|
||||||
invoiceId: string;
|
|
||||||
mode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UnifiedInvoicePage({
|
|
||||||
invoiceId,
|
|
||||||
mode,
|
|
||||||
}: UnifiedInvoicePageProps) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Always render InvoiceForm to preserve state, but hide when in view mode */}
|
|
||||||
<div className={mode === "edit" ? "block" : "hidden"}>
|
|
||||||
<InvoiceForm invoiceId={invoiceId} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Show InvoiceView only when in view mode */}
|
|
||||||
{mode === "view" && <InvoiceView invoiceId={invoiceId} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { InvoiceForm } from "~/components/forms/invoice-form";
|
import InvoiceForm from "~/components/forms/invoice-form";
|
||||||
|
|
||||||
export default function EditInvoicePage() {
|
export default function InvoiceFormPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const invoiceId = params.id as string;
|
const id = params.id as string;
|
||||||
|
|
||||||
return <InvoiceForm invoiceId={invoiceId} />;
|
// Pass the actual id, let the form component handle the logic
|
||||||
|
return <InvoiceForm invoiceId={id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,91 @@
|
|||||||
import { Suspense } from "react";
|
"use client";
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
|
import { DollarSign, Edit, Loader2, Trash2 } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { api, HydrateClient } from "~/trpc/server";
|
import { notFound, useParams, useRouter } from "next/navigation";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { toast } from "sonner";
|
||||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||||
import { Separator } from "~/components/ui/separator";
|
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
import { PDFDownloadButton } from "./_components/pdf-download-button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { SendInvoiceButton } from "./_components/send-invoice-button";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import {
|
||||||
|
getEffectiveInvoiceStatus,
|
||||||
|
isInvoiceOverdue,
|
||||||
|
} from "~/lib/invoice-status";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
|
import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
|
||||||
|
import { PDFDownloadButton } from "./_components/pdf-download-button";
|
||||||
|
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
Building,
|
Building,
|
||||||
Edit,
|
Check,
|
||||||
FileText,
|
FileText,
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Phone,
|
Phone,
|
||||||
User,
|
User,
|
||||||
AlertTriangle,
|
|
||||||
Check,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface InvoicePageProps {
|
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||||
params: Promise<{ id: string }>;
|
const router = useRouter();
|
||||||
}
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
|
||||||
const invoice = await api.invoices.getById({ id: invoiceId });
|
id: invoiceId,
|
||||||
|
});
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const deleteInvoice = api.invoices.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Invoice deleted successfully");
|
||||||
|
router.push("/dashboard/invoices");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message ?? "Failed to delete invoice");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(data.message);
|
||||||
|
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message ?? "Failed to update invoice status");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAsPaid = () => {
|
||||||
|
updateStatus.mutate({
|
||||||
|
id: invoiceId,
|
||||||
|
status: "paid" as StoredInvoiceStatus,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
deleteInvoice.mutate({ id: invoiceId });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <InvoiceDetailsSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
notFound();
|
notFound();
|
||||||
@@ -42,40 +99,44 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
}).format(new Date(date));
|
}).format(new Date(date));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number, currency = invoice.currency) => {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency,
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||||
const total = subtotal + taxAmount;
|
const total = subtotal + taxAmount;
|
||||||
const isOverdue =
|
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||||
new Date(invoice.dueDate) < new Date() && invoice.status !== "paid";
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
);
|
||||||
|
const isOverdue = isInvoiceOverdue(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
);
|
||||||
|
|
||||||
const getStatusType = (): StatusType => {
|
const getStatusType = (): StatusType => {
|
||||||
if (invoice.status === "paid") return "paid";
|
return effectiveStatus as StatusType;
|
||||||
if (invoice.status === "draft") return "draft";
|
|
||||||
if (invoice.status === "overdue") return "overdue";
|
|
||||||
if (invoice.status === "sent") {
|
|
||||||
return isOverdue ? "overdue" : "sent";
|
|
||||||
}
|
|
||||||
return "draft";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-24">
|
<div className="page-enter space-y-6 pb-24">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Invoice Details"
|
title="Invoice Details"
|
||||||
description="View and manage invoice information"
|
description="View and manage invoice information"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<PDFDownloadButton invoiceId={invoice.id} variant="outline" />
|
<PDFDownloadButton
|
||||||
<Button asChild variant="default">
|
invoiceId={invoice.id}
|
||||||
|
variant="outline"
|
||||||
|
className="hover-lift"
|
||||||
|
/>
|
||||||
|
<Button asChild variant="default" className="hover-lift">
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
<Edit className="h-5 w-5" />
|
<Edit className="mr-2 h-5 w-5" />
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -86,13 +147,13 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
{/* Invoice Header */}
|
{/* Invoice Header */}
|
||||||
<Card className="card-primary">
|
<Card>
|
||||||
<CardContent className="p-4 sm:p-6">
|
<CardContent className="p-4 sm:p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-start justify-between gap-6">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
<h2 className="text-foreground truncate text-2xl font-bold">
|
<h2 className="text-foreground text-2xl font-bold break-words">
|
||||||
{invoice.invoiceNumber}
|
{invoice.invoiceNumber}
|
||||||
</h2>
|
</h2>
|
||||||
<StatusBadge status={getStatusType()} />
|
<StatusBadge status={getStatusType()} />
|
||||||
@@ -106,7 +167,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-right">
|
<div className="flex-shrink-0 text-left sm:text-right">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Total Amount
|
Total Amount
|
||||||
</p>
|
</p>
|
||||||
@@ -121,7 +182,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{/* Overdue Alert */}
|
{/* Overdue Alert */}
|
||||||
{isOverdue && (
|
{isOverdue && (
|
||||||
<Card className="border-destructive/20 bg-destructive/5 card-secondary">
|
<Card className="border-destructive/20 bg-destructive/5">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-destructive flex items-center gap-3">
|
<div className="text-destructive flex items-center gap-3">
|
||||||
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
||||||
@@ -144,7 +205,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
{/* Client & Business Info */}
|
{/* Client & Business Info */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{/* Client Information */}
|
{/* Client Information */}
|
||||||
<Card className="card-primary">
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<User className="h-5 w-5" />
|
<User className="h-5 w-5" />
|
||||||
@@ -161,7 +222,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{invoice.client.email && (
|
{invoice.client.email && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Mail className="text-primary h-4 w-4" />
|
<Mail className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm break-all">
|
<span className="text-sm break-all">
|
||||||
@@ -172,7 +233,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{invoice.client.phone && (
|
{invoice.client.phone && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Phone className="text-primary h-4 w-4" />
|
<Phone className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm">{invoice.client.phone}</span>
|
<span className="text-sm">{invoice.client.phone}</span>
|
||||||
@@ -181,7 +242,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{(invoice.client.addressLine1 ?? invoice.client.city) && (
|
{(invoice.client.addressLine1 ?? invoice.client.city) && (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="bg-primary/10 rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<MapPin className="text-primary h-4 w-4" />
|
<MapPin className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
@@ -216,7 +277,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{/* Business Information */}
|
{/* Business Information */}
|
||||||
{invoice.business && (
|
{invoice.business && (
|
||||||
<Card className="card-primary">
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Building className="h-5 w-5" />
|
<Building className="h-5 w-5" />
|
||||||
@@ -233,7 +294,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{invoice.business.email && (
|
{invoice.business.email && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Mail className="text-primary h-4 w-4" />
|
<Mail className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm break-all">
|
<span className="text-sm break-all">
|
||||||
@@ -244,7 +305,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{invoice.business.phone && (
|
{invoice.business.phone && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 rounded-lg p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Phone className="text-primary h-4 w-4" />
|
<Phone className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
@@ -259,7 +320,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invoice Items */}
|
{/* Invoice Items */}
|
||||||
<Card className="card-primary">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
@@ -267,48 +328,52 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{invoice.items.map((item) => (
|
{invoice.items.map((item, _index) => (
|
||||||
<Card key={item.id} className="card-secondary">
|
<Card key={item.id} className="invoice-item bg-secondary">
|
||||||
<CardContent className="py-2">
|
<CardContent className="p-3">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-foreground mb-2 text-base font-medium">
|
<p className="text-foreground mb-2 text-base font-medium break-words">
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground text-sm">
|
||||||
<span className="inline whitespace-nowrap">
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
<span className="whitespace-nowrap">
|
||||||
{formatDate(item.date).replace(/ /g, "\u00A0")}
|
{formatDate(item.date).replace(/ /g, "\u00A0")}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline whitespace-nowrap before:mx-2 before:content-['_|_']">
|
<span className="whitespace-nowrap">
|
||||||
{item.hours.toString().replace(/ /g, "\u00A0")}
|
{item.hours.toString().replace(/ /g, "\u00A0")}
|
||||||
hours
|
hours
|
||||||
</span>
|
</span>
|
||||||
<span className="inline whitespace-nowrap before:mx-2 before:content-['_|_']">
|
<span className="whitespace-nowrap">
|
||||||
@ ${item.rate}/hr
|
@ ${item.rate}/hr
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-right">
|
</div>
|
||||||
|
<div className="flex-shrink-0 self-start">
|
||||||
<p className="text-primary text-lg font-semibold">
|
<p className="text-primary text-lg font-semibold">
|
||||||
{formatCurrency(item.amount)}
|
{formatCurrency(item.amount)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Totals */}
|
{/* Totals */}
|
||||||
<div className="bg-muted/30 rounded-lg p-4">
|
<div className="bg-secondary rounded-lg p-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between">
|
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
|
||||||
<span className="text-muted-foreground">Subtotal:</span>
|
<span className="text-muted-foreground">Subtotal:</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatCurrency(subtotal)}
|
{formatCurrency(subtotal)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{invoice.taxRate > 0 && (
|
{invoice.taxRate > 0 && (
|
||||||
<div className="flex justify-between">
|
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
Tax ({invoice.taxRate}%):
|
Tax ({invoice.taxRate}%):
|
||||||
</span>
|
</span>
|
||||||
@@ -318,7 +383,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex justify-between text-lg font-bold">
|
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-lg font-bold">
|
||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<span className="text-primary">
|
<span className="text-primary">
|
||||||
{formatCurrency(total)}
|
{formatCurrency(total)}
|
||||||
@@ -331,7 +396,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{invoice.notes && (
|
{invoice.notes && (
|
||||||
<Card className="card-primary">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Notes</CardTitle>
|
<CardTitle>Notes</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -346,7 +411,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="card-primary sticky top-6">
|
<Card className="lg:sticky lg:top-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Check className="h-5 w-5" />
|
<Check className="h-5 w-5" />
|
||||||
@@ -354,7 +419,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<Button asChild variant="outline" className="w-full">
|
<Button asChild variant="secondary" className="w-full">
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit Invoice
|
Edit Invoice
|
||||||
@@ -362,28 +427,117 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{invoice.items && invoice.client && (
|
{invoice.items && invoice.client && (
|
||||||
<PDFDownloadButton invoiceId={invoice.id} className="w-full" />
|
<PDFDownloadButton
|
||||||
|
invoiceId={invoice.id}
|
||||||
|
className="w-full"
|
||||||
|
variant="secondary"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{invoice.status === "draft" && (
|
{/* Send Invoice Button - Show for draft, sent, and overdue */}
|
||||||
<SendInvoiceButton invoiceId={invoice.id} className="w-full" />
|
{effectiveStatus === "draft" && (
|
||||||
|
<EnhancedSendInvoiceButton
|
||||||
|
invoiceId={invoice.id}
|
||||||
|
className="w-full"
|
||||||
|
variant="secondary"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(effectiveStatus === "sent" ||
|
||||||
|
effectiveStatus === "overdue") && (
|
||||||
|
<EnhancedSendInvoiceButton
|
||||||
|
invoiceId={invoice.id}
|
||||||
|
className="w-full"
|
||||||
|
showResend={true}
|
||||||
|
variant="secondary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manual Status Updates */}
|
||||||
|
{(effectiveStatus === "sent" ||
|
||||||
|
effectiveStatus === "overdue") && (
|
||||||
|
<Button
|
||||||
|
onClick={handleMarkAsPaid}
|
||||||
|
disabled={updateStatus.isPending}
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{updateStatus.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<DollarSign className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Mark as Paid
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteInvoice.isPending}
|
||||||
|
className="text-destructive hover:bg-destructive/10 w-full"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete Invoice
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Invoice</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete invoice{" "}
|
||||||
|
<strong>{invoice.invoiceNumber}</strong>? This action cannot be
|
||||||
|
undone and will permanently remove the invoice and all its data.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
|
disabled={deleteInvoice.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={confirmDelete}
|
||||||
|
disabled={deleteInvoice.isPending}
|
||||||
|
>
|
||||||
|
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function InvoicePage({ params }: InvoicePageProps) {
|
export default function InvoiceViewPage() {
|
||||||
const { id } = await params;
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
// Handle /invoices/new route - redirect to dedicated new page
|
||||||
|
useEffect(() => {
|
||||||
|
if (id === "new") {
|
||||||
|
router.replace("/dashboard/invoices/new");
|
||||||
|
}
|
||||||
|
}, [id, router]);
|
||||||
|
|
||||||
|
// Don't render anything if we're redirecting
|
||||||
|
if (id === "new") {
|
||||||
return (
|
return (
|
||||||
<HydrateClient>
|
<div className="flex h-96 items-center justify-center">
|
||||||
<Suspense fallback={<InvoiceDetailsSkeleton />}>
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
<InvoiceContent invoiceId={id} />
|
</div>
|
||||||
</Suspense>
|
|
||||||
</HydrateClient>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <InvoiceViewContent invoiceId={id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,649 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||||
|
import { EmailComposer } from "~/components/forms/email-composer";
|
||||||
|
import { EmailPreview } from "~/components/forms/email-preview";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Send,
|
||||||
|
Eye,
|
||||||
|
Edit3,
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowLeft,
|
||||||
|
Loader2,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
function SendEmailPageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pb-32">
|
||||||
|
<PageHeader
|
||||||
|
title="Loading..."
|
||||||
|
description="Loading invoice email"
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
<div className="bg-muted h-96 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-muted h-64 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainTextToHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmailNoteHtml(value: string) {
|
||||||
|
const visibleText = value
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/p>/gi, "\n")
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/ |\u00a0/g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return visibleText ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SendEmailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const invoiceId = params.id as string;
|
||||||
|
|
||||||
|
// State management
|
||||||
|
const [activeTab, setActiveTab] = useState("compose");
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
|
||||||
|
// Email content state
|
||||||
|
const [subject, setSubject] = useState("");
|
||||||
|
const [emailContent, setEmailContent] = useState("");
|
||||||
|
const [ccEmail, setCcEmail] = useState("");
|
||||||
|
const [bccEmail, setBccEmail] = useState("");
|
||||||
|
const [customMessage, setCustomMessage] = useState("");
|
||||||
|
|
||||||
|
// Fetch invoice data
|
||||||
|
const { data: invoiceData, isLoading: invoiceLoading } =
|
||||||
|
api.invoices.getById.useQuery({
|
||||||
|
id: invoiceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get utils for cache invalidation
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
// Email sending mutation
|
||||||
|
const sendEmailMutation = api.email.sendInvoice.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success("Email sent successfully!", {
|
||||||
|
description: data.message,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate back to invoice view
|
||||||
|
router.push(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
|
||||||
|
// Refresh invoice data
|
||||||
|
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
let errorMessage = "Failed to send invoice email";
|
||||||
|
let errorDescription = error.message;
|
||||||
|
let canRetry = false;
|
||||||
|
|
||||||
|
if (error.message.includes("Invalid recipient")) {
|
||||||
|
errorMessage = "Invalid Email Address";
|
||||||
|
errorDescription =
|
||||||
|
"Please check the client's email address and try again.";
|
||||||
|
} else if (error.message.includes("domain not verified")) {
|
||||||
|
errorMessage = "Email Configuration Issue";
|
||||||
|
errorDescription = "Please contact support to configure email sending.";
|
||||||
|
} else if (error.message.includes("rate limit")) {
|
||||||
|
errorMessage = "Too Many Emails";
|
||||||
|
errorDescription = "Please wait a moment before sending another email.";
|
||||||
|
canRetry = true;
|
||||||
|
} else if (error.message.includes("no email address")) {
|
||||||
|
errorMessage = "No Email Address";
|
||||||
|
errorDescription = "This client doesn't have an email address on file.";
|
||||||
|
} else if (
|
||||||
|
error.message.includes("unavailable") ||
|
||||||
|
error.message.includes("timeout")
|
||||||
|
) {
|
||||||
|
errorMessage = "Service Temporarily Unavailable";
|
||||||
|
errorDescription =
|
||||||
|
"The email service is temporarily unavailable. Please try again.";
|
||||||
|
canRetry = true;
|
||||||
|
} else {
|
||||||
|
canRetry = true; // Allow retry for unknown errors
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(errorMessage, {
|
||||||
|
description:
|
||||||
|
canRetry && retryCount < 2
|
||||||
|
? `${errorDescription} You can retry this operation.`
|
||||||
|
: errorDescription,
|
||||||
|
duration: 6000,
|
||||||
|
action:
|
||||||
|
canRetry && retryCount < 2
|
||||||
|
? {
|
||||||
|
label: "Retry",
|
||||||
|
onClick: () => handleRetry(),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsSending(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform invoice data for components
|
||||||
|
const invoice = useMemo(() => {
|
||||||
|
return invoiceData
|
||||||
|
? {
|
||||||
|
id: invoiceData.id,
|
||||||
|
invoiceNumber: invoiceData.invoiceNumber,
|
||||||
|
issueDate: invoiceData.issueDate,
|
||||||
|
dueDate: invoiceData.dueDate,
|
||||||
|
status: invoiceData.status,
|
||||||
|
totalAmount: invoiceData.totalAmount,
|
||||||
|
taxRate: invoiceData.taxRate,
|
||||||
|
currency: invoiceData.currency,
|
||||||
|
emailMessage: invoiceData.emailMessage,
|
||||||
|
client: invoiceData.client
|
||||||
|
? {
|
||||||
|
name: invoiceData.client.name,
|
||||||
|
email: invoiceData.client.email,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
business: invoiceData.business
|
||||||
|
? {
|
||||||
|
name: invoiceData.business.name,
|
||||||
|
nickname: invoiceData.business.nickname,
|
||||||
|
email: invoiceData.business.email,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
items: invoiceData.items?.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
date: item.date,
|
||||||
|
description: item.description,
|
||||||
|
hours: item.hours,
|
||||||
|
rate: item.rate,
|
||||||
|
amount: item.amount,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
}, [invoiceData]);
|
||||||
|
|
||||||
|
const normalizedCustomMessage = useMemo(
|
||||||
|
() => normalizeEmailNoteHtml(customMessage),
|
||||||
|
[customMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize email content when invoice loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!invoice || isInitialized) return;
|
||||||
|
|
||||||
|
// Set default subject
|
||||||
|
const defaultSubject = `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`;
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setSubject(defaultSubject);
|
||||||
|
|
||||||
|
// Set default content (empty since template handles everything)
|
||||||
|
const defaultContent = ``;
|
||||||
|
|
||||||
|
setEmailContent(defaultContent);
|
||||||
|
setCustomMessage(
|
||||||
|
invoice.emailMessage ? plainTextToHtml(invoice.emailMessage) : "",
|
||||||
|
);
|
||||||
|
setIsInitialized(true);
|
||||||
|
}, [invoice, isInitialized]);
|
||||||
|
|
||||||
|
const handleSendEmail = async () => {
|
||||||
|
if (!invoice?.client?.email || invoice.client.email.trim() === "") {
|
||||||
|
toast.error("No email address", {
|
||||||
|
description: "This client doesn't have an email address on file.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subject.trim()) {
|
||||||
|
toast.error("Subject required", {
|
||||||
|
description: "Please enter an email subject before sending.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show confirmation dialog
|
||||||
|
setShowConfirmDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmSendEmail = async () => {
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
setIsSending(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmailMutation.mutateAsync({
|
||||||
|
invoiceId,
|
||||||
|
customSubject: subject,
|
||||||
|
customContent: emailContent,
|
||||||
|
customMessage: normalizedCustomMessage,
|
||||||
|
useHtml: true,
|
||||||
|
ccEmails: ccEmail.trim() || undefined,
|
||||||
|
bccEmails: bccEmail.trim() || undefined,
|
||||||
|
});
|
||||||
|
setRetryCount(0); // Reset retry count on success
|
||||||
|
} catch {
|
||||||
|
// Error handling is done in the mutation's onError
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
if (retryCount < 2) {
|
||||||
|
setRetryCount((prev) => prev + 1);
|
||||||
|
void confirmSendEmail();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com";
|
||||||
|
const toEmail = invoice?.client?.email ?? "";
|
||||||
|
|
||||||
|
const canSend =
|
||||||
|
!isSending && subject.trim() && toEmail && toEmail.trim() !== "";
|
||||||
|
|
||||||
|
if (invoiceLoading) {
|
||||||
|
return <SendEmailPageSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return (
|
||||||
|
<div className="page-enter space-y-6">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>Invoice not found.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-enter space-y-6 pb-32">
|
||||||
|
<PageHeader
|
||||||
|
title={`Send Invoice ${invoice.invoiceNumber}`}
|
||||||
|
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"} • ${new Intl.DateTimeFormat(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
},
|
||||||
|
).format(new Date())}`}
|
||||||
|
variant="gradient"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(`/dashboard/invoices/${invoiceId}`)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Invoice
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{/* Warning for missing email */}
|
||||||
|
{(!toEmail || toEmail.trim() === "") && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
This client doesn't have an email address. Please add an email
|
||||||
|
address to the client before sending the invoice.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="compose" className="flex items-center gap-2">
|
||||||
|
<Edit3 className="h-4 w-4" />
|
||||||
|
Compose
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="preview" className="flex items-center gap-2">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
Preview
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<TabsContent value="compose" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
Compose Email
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isInitialized ? (
|
||||||
|
<EmailComposer
|
||||||
|
subject={subject}
|
||||||
|
onSubjectChange={setSubject}
|
||||||
|
content={emailContent}
|
||||||
|
onContentChange={setEmailContent}
|
||||||
|
customMessage={customMessage}
|
||||||
|
onCustomMessageChange={setCustomMessage}
|
||||||
|
fromEmail={fromEmail}
|
||||||
|
toEmail={toEmail}
|
||||||
|
ccEmail={ccEmail}
|
||||||
|
onCcEmailChange={setCcEmail}
|
||||||
|
bccEmail={bccEmail}
|
||||||
|
onBccEmailChange={setBccEmail}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="bg-muted flex h-[400px] items-center justify-center border">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin border-2 border-t-transparent"></div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Initializing email content...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="preview" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Eye className="h-5 w-5" />
|
||||||
|
Email Preview
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<EmailPreview
|
||||||
|
subject={subject}
|
||||||
|
fromEmail={fromEmail}
|
||||||
|
toEmail={toEmail}
|
||||||
|
ccEmail={ccEmail}
|
||||||
|
bccEmail={bccEmail}
|
||||||
|
content={emailContent}
|
||||||
|
customMessage={normalizedCustomMessage}
|
||||||
|
invoice={invoice}
|
||||||
|
className="min-w-0 border-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Invoice Summary */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<FileText className="text-primary h-5 w-5" />
|
||||||
|
Invoice #{invoice.invoiceNumber}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-sm font-medium">
|
||||||
|
Client
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{invoice.client?.name ?? "Client"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-sm font-medium">
|
||||||
|
Issue Date
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm">
|
||||||
|
{new Intl.DateTimeFormat("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(new Date(invoice.issueDate))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-sm font-medium">
|
||||||
|
Status
|
||||||
|
</Label>
|
||||||
|
<Badge variant="outline">{invoice.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Email Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-sm font-medium">
|
||||||
|
From
|
||||||
|
</Label>
|
||||||
|
<p className="font-mono text-sm break-all">{fromEmail}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-sm font-medium">
|
||||||
|
To
|
||||||
|
</Label>
|
||||||
|
<p className="font-mono text-sm break-all">
|
||||||
|
{toEmail || "No email address"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{ccEmail && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-sm font-medium">
|
||||||
|
CC
|
||||||
|
</Label>
|
||||||
|
<p className="font-mono text-sm break-all">{ccEmail}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bccEmail && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-sm font-medium">
|
||||||
|
BCC
|
||||||
|
</Label>
|
||||||
|
<p className="font-mono text-sm break-all">{bccEmail}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-sm font-medium">
|
||||||
|
Subject
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm break-words">{subject || "No subject"}</p>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-sm font-medium">
|
||||||
|
Attachment
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
<span>invoice-{invoice.invoiceNumber}.pdf</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{activeTab === "compose" && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab("preview")}
|
||||||
|
disabled={!subject.trim()}
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Preview Email
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "preview" && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab("compose")}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Edit3 className="mr-2 h-4 w-4" />
|
||||||
|
Edit Email
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Action Bar */}
|
||||||
|
<FloatingActionBar
|
||||||
|
leftContent={
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="bg-primary/10 p-2">
|
||||||
|
<Send className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-foreground font-medium">Send Invoice</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Email invoice to {invoice.client?.name ?? "client"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push(`/dashboard/invoices/${invoiceId}`)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSendEmail}
|
||||||
|
disabled={!canSend || isSending}
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isSending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
|
||||||
|
<span className="hidden sm:inline">Sending...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="h-4 w-4 sm:mr-2" />
|
||||||
|
<span className="hidden sm:inline">Send Email</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</FloatingActionBar>
|
||||||
|
|
||||||
|
{/* Confirmation Dialog */}
|
||||||
|
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Confirm</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Send this invoice email to <strong>{toEmail}</strong>
|
||||||
|
{ccEmail && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
with CC to <strong>{ccEmail}</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{bccEmail && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
and BCC to <strong>{bccEmail}</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
?
|
||||||
|
</DialogDescription>
|
||||||
|
{retryCount > 0 && (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Retry attempt {retryCount} of 2
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="bg-muted/30 space-y-2 border p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Subject: </span>
|
||||||
|
<span className="font-medium">{subject}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Attachment: </span>
|
||||||
|
<span>invoice-{invoice.invoiceNumber}.pdf</span>
|
||||||
|
</div>
|
||||||
|
{normalizedCustomMessage && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Email note: </span>
|
||||||
|
<span>Included</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowConfirmDialog(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={confirmSendEmail} variant="default">
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,43 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef, Row } from "@tanstack/react-table";
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||||
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
|
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
|
||||||
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
||||||
import { Eye, Edit } from "lucide-react";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
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 {
|
interface Invoice {
|
||||||
id: string;
|
id: string;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
@@ -20,6 +48,7 @@ interface Invoice {
|
|||||||
status: string;
|
status: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
|
currency: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdById: string;
|
createdById: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -53,40 +82,85 @@ interface InvoicesDataTableProps {
|
|||||||
invoices: Invoice[];
|
invoices: Invoice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusType = (invoice: Invoice): StatusType => {
|
const getStatusType = (invoice: Invoice): StatusType =>
|
||||||
if (invoice.status === "paid") return "paid";
|
getEffectiveInvoiceStatus(
|
||||||
if (invoice.status === "draft") return "draft";
|
invoice.status as StoredInvoiceStatus,
|
||||||
if (invoice.status === "overdue") return "overdue";
|
invoice.dueDate,
|
||||||
if (invoice.status === "sent") {
|
) as StatusType;
|
||||||
const dueDate = new Date(invoice.dueDate);
|
|
||||||
return dueDate < new Date() ? "overdue" : "sent";
|
|
||||||
}
|
|
||||||
return "draft";
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) =>
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
new Intl.DateTimeFormat("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
}).format(new Date(date));
|
}).format(new Date(date));
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
|
||||||
|
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||||
|
const [pendingBulkDelete, setPendingBulkDelete] = useState<Invoice[]>([]);
|
||||||
|
|
||||||
const handleRowClick = (invoice: Invoice) => {
|
const utils = api.useUtils();
|
||||||
router.push(`/dashboard/invoices/${invoice.id}`);
|
|
||||||
};
|
const deleteInvoice = api.invoices.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Invoice deleted");
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setInvoiceToDelete(null);
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message ?? "Failed to delete invoice"),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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>[] = [
|
const columns: ColumnDef<Invoice>[] = [
|
||||||
|
{
|
||||||
|
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",
|
accessorKey: "client.name",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@@ -95,13 +169,27 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const invoice = row.original;
|
const invoice = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[80px] min-w-0 sm:max-w-[200px] lg:max-w-[300px]">
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-primary/10 hidden p-2 sm:flex">
|
||||||
|
<FileText className="text-primary h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-medium">
|
<p className="truncate font-medium">
|
||||||
{invoice.client?.name ?? "—"}
|
{invoice.client?.name ?? "—"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
||||||
{invoice.invoiceNumber}
|
{invoice.invoiceNumber}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -111,32 +199,32 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Date" />
|
<DataTableColumnHeader column={column} title="Date" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const date = row.getValue("issueDate");
|
|
||||||
return (
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm">{formatDate(date as Date)}</p>
|
<p className="truncate text-sm">
|
||||||
|
{formatDate(row.getValue("issueDate"))}
|
||||||
|
</p>
|
||||||
<p className="text-muted-foreground truncate text-xs">
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
Due {formatDate(new Date(row.original.dueDate))}
|
Due {formatDate(new Date(row.original.dueDate))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Status" />
|
<DataTableColumnHeader column={column} title="Status" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const invoice = row.original;
|
<StatusBadge
|
||||||
return <StatusBadge status={getStatusType(invoice)} />;
|
status={getStatusType(row.original)}
|
||||||
},
|
className={
|
||||||
filterFn: (row, id, value: string[]) => {
|
getStatusType(row.original) === "sent" ? "status-pending" : ""
|
||||||
const invoice = row.original;
|
}
|
||||||
const status = getStatusType(invoice);
|
/>
|
||||||
return value.includes(status);
|
),
|
||||||
},
|
filterFn: (row, _id, value: string[]) =>
|
||||||
|
value.includes(getStatusType(row.original)),
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "hidden sm:table-cell",
|
headerClassName: "hidden sm:table-cell",
|
||||||
cellClassName: "hidden sm:table-cell",
|
cellClassName: "hidden sm:table-cell",
|
||||||
@@ -147,19 +235,16 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Amount" />
|
<DataTableColumnHeader column={column} title="Amount" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const amount = row.getValue("totalAmount");
|
|
||||||
return (
|
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-semibold">
|
<p className="text-sm font-semibold">
|
||||||
{formatCurrency(amount as number)}
|
{formatCurrency(row.getValue("totalAmount"), row.original.currency)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{row.original.items?.length ?? 0} items
|
{row.original.items?.length ?? 0} items
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
),
|
||||||
},
|
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "hidden sm:table-cell",
|
headerClassName: "hidden sm:table-cell",
|
||||||
cellClassName: "hidden sm:table-cell",
|
cellClassName: "hidden sm:table-cell",
|
||||||
@@ -175,7 +260,7 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-8 p-0"
|
className="hover-scale h-8 w-8 p-0"
|
||||||
data-action-button="true"
|
data-action-button="true"
|
||||||
>
|
>
|
||||||
<Eye className="h-3.5 w-3.5" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
@@ -185,12 +270,25 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-8 p-0"
|
className="hover-scale h-8 w-8 p-0"
|
||||||
data-action-button="true"
|
data-action-button="true"
|
||||||
>
|
>
|
||||||
<Edit className="h-3.5 w-3.5" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setInvoiceToDelete(invoice);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}}
|
||||||
|
data-action-button="true"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
{invoice.items && invoice.client && (
|
{invoice.items && invoice.client && (
|
||||||
<div data-action-button="true">
|
<div data-action-button="true">
|
||||||
<PDFDownloadButton invoiceId={invoice.id} variant="icon" />
|
<PDFDownloadButton invoiceId={invoice.id} variant="icon" />
|
||||||
@@ -216,13 +314,153 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={invoices}
|
data={invoices}
|
||||||
searchKey="invoiceNumber"
|
searchKey="invoiceNumber"
|
||||||
searchPlaceholder="Search invoices..."
|
searchPlaceholder="Search invoices..."
|
||||||
filterableColumns={filterableColumns}
|
filterableColumns={filterableColumns}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={(invoice) =>
|
||||||
|
router.push(`/dashboard/invoices/${invoice.id}`)
|
||||||
|
}
|
||||||
|
selectionActions={(selected, clear) => (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={bulkUpdateStatus.isPending}
|
||||||
|
>
|
||||||
|
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Mark as
|
||||||
|
<ChevronDown className="ml-1.5 h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
bulkUpdateStatus.mutate(
|
||||||
|
{ ids: selected.map((i) => i.id), status: "sent" },
|
||||||
|
{ onSuccess: clear },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-4 w-4" /> Mark Sent
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
bulkUpdateStatus.mutate(
|
||||||
|
{ ids: selected.map((i) => i.id), status: "paid" },
|
||||||
|
{ onSuccess: clear },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" /> Mark Paid
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
bulkUpdateStatus.mutate(
|
||||||
|
{ ids: selected.map((i) => i.id), status: "draft" },
|
||||||
|
{ onSuccess: clear },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FileText className="mr-2 h-4 w-4" /> Mark Draft
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={bulkDelete.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setPendingBulkDelete(selected);
|
||||||
|
setBulkDeleteDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Delete ({selected.length})
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Single delete dialog */}
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Invoice</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete invoice{" "}
|
||||||
|
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
|
||||||
|
<strong>{invoiceToDelete?.client?.name}</strong>? This action
|
||||||
|
cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
|
disabled={deleteInvoice.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() =>
|
||||||
|
invoiceToDelete &&
|
||||||
|
deleteInvoice.mutate({ id: invoiceToDelete.id })
|
||||||
|
}
|
||||||
|
disabled={deleteInvoice.isPending}
|
||||||
|
>
|
||||||
|
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,36 @@
|
|||||||
import { Suspense } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { HydrateClient } from "~/trpc/server";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
import { CSVImportPage } from "~/components/csv-import-page";
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
|
||||||
Upload,
|
|
||||||
FileText,
|
|
||||||
Download,
|
|
||||||
CheckCircle,
|
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Info,
|
ArrowLeft,
|
||||||
|
CheckCircle,
|
||||||
|
Download,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
|
FileText,
|
||||||
|
Info,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CSVImportPage } from "~/components/csv-import-page";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { HydrateClient } from "~/trpc/server";
|
||||||
|
|
||||||
// File Upload Instructions Component
|
// File Upload Instructions Component
|
||||||
function FormatInstructions() {
|
function FormatInstructions() {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
{/* Required Format */}
|
{/* Required Format */}
|
||||||
<Card className="card-primary">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="card-title-info">
|
<CardTitle className="text-foreground flex items-center gap-2">
|
||||||
<FileText className="text-icon-blue h-5 w-5" />
|
<FileText className="text-primary h-5 w-5" />
|
||||||
Required CSV Format
|
Required CSV Format
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="bg-muted-subtle rounded-lg p-4">
|
<div className="bg-muted/50 p-4">
|
||||||
<p className="text-secondary font-mono text-sm">
|
<p className="text-muted-foreground font-mono text-sm">
|
||||||
DATE,DESCRIPTION,HOURS,RATE,AMOUNT
|
DATE,DESCRIPTION,HOURS,RATE,AMOUNT
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,7 +49,7 @@ function FormatInstructions() {
|
|||||||
},
|
},
|
||||||
].map((col) => (
|
].map((col) => (
|
||||||
<div key={col.field} className="flex items-start gap-3">
|
<div key={col.field} className="flex items-start gap-3">
|
||||||
<Badge className="badge-outline text-xs">{col.field}</Badge>
|
<Badge className="border text-xs">{col.field}</Badge>
|
||||||
<span className="text-muted-foreground text-sm">
|
<span className="text-muted-foreground text-sm">
|
||||||
{col.desc}
|
{col.desc}
|
||||||
</span>
|
</span>
|
||||||
@@ -73,10 +72,10 @@ function FormatInstructions() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Sample Data & Download */}
|
{/* Sample Data & Download */}
|
||||||
<Card className="card-primary">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="card-title-secondary">
|
<CardTitle className="text-foreground flex items-center gap-2">
|
||||||
<Download className="text-icon-green h-5 w-5" />
|
<Download className="text-primary h-5 w-5" />
|
||||||
Sample Template
|
Sample Template
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -86,9 +85,9 @@ function FormatInstructions() {
|
|||||||
for importing time entries.
|
for importing time entries.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-green-subtle rounded-lg p-4">
|
<div className="bg-primary/10 p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Info className="text-icon-green mt-0.5 h-5 w-5" />
|
<Info className="text-primary mt-0.5 h-5 w-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-success text-sm font-medium">Pro Tip</p>
|
<p className="text-success text-sm font-medium">Pro Tip</p>
|
||||||
<p className="text-success text-sm">
|
<p className="text-success text-sm">
|
||||||
@@ -101,7 +100,7 @@ function FormatInstructions() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold">Sample Row:</h4>
|
<h4 className="text-sm font-semibold">Sample Row:</h4>
|
||||||
<div className="bg-muted-subtle rounded-lg p-3">
|
<div className="bg-muted/50 p-3">
|
||||||
<p className="text-muted font-mono text-xs break-all">
|
<p className="text-muted font-mono text-xs break-all">
|
||||||
1/15/24,"Web development work",8,75.00,600.00
|
1/15/24,"Web development work",8,75.00,600.00
|
||||||
</p>
|
</p>
|
||||||
@@ -110,7 +109,7 @@ function FormatInstructions() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold">Sample Filename:</h4>
|
<h4 className="text-sm font-semibold">Sample Filename:</h4>
|
||||||
<div className="bg-muted-subtle rounded-lg p-3">
|
<div className="bg-muted/50 p-3">
|
||||||
<p className="text-muted font-mono text-xs">2024-01-15.csv</p>
|
<p className="text-muted font-mono text-xs">2024-01-15.csv</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,10 +122,10 @@ function FormatInstructions() {
|
|||||||
// Important Notes Section
|
// Important Notes Section
|
||||||
function ImportantNotes() {
|
function ImportantNotes() {
|
||||||
return (
|
return (
|
||||||
<Card className="card-primary border-l-4 border-l-amber-500">
|
<Card className="bg-card border-border border border-l-4 border-l-amber-500">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="card-title-warning">
|
<CardTitle className="text-destructive flex items-center gap-2">
|
||||||
<AlertCircle className="text-icon-amber h-5 w-5" />
|
<AlertCircle className="text-primary h-5 w-5" />
|
||||||
Important Notes
|
Important Notes
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -159,18 +158,18 @@ function ImportantNotes() {
|
|||||||
// File Format Help Section
|
// File Format Help Section
|
||||||
function FileFormatHelp() {
|
function FileFormatHelp() {
|
||||||
return (
|
return (
|
||||||
<Card className="card-primary">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="card-title-info">
|
<CardTitle className="text-foreground flex items-center gap-2">
|
||||||
<FileSpreadsheet className="text-icon-blue h-5 w-5" />
|
<FileSpreadsheet className="text-primary h-5 w-5" />
|
||||||
Supported File Formats
|
Supported File Formats
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<div className="mx-auto w-fit rounded-full bg-blue-50 p-3 dark:bg-blue-900/20">
|
<div className="bg-accent mx-auto w-fit p-3">
|
||||||
<FileSpreadsheet className="h-6 w-6 text-blue-600" />
|
<FileSpreadsheet className="text-foreground-foreground h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-semibold">CSV Files</h4>
|
<h4 className="font-semibold">CSV Files</h4>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
@@ -179,8 +178,8 @@ function FileFormatHelp() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<div className="mx-auto w-fit rounded-full bg-green-50 p-3 dark:bg-green-900/20">
|
<div className="bg-primary/10 mx-auto w-fit p-3">
|
||||||
<Upload className="h-6 w-6 text-green-600" />
|
<Upload className="text-primary h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-semibold">Max Size</h4>
|
<h4 className="font-semibold">Max Size</h4>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
@@ -188,8 +187,8 @@ function FileFormatHelp() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<div className="mx-auto w-fit rounded-full bg-purple-50 p-3 dark:bg-purple-900/20">
|
<div className="bg-secondary mx-auto w-fit p-3">
|
||||||
<CheckCircle className="h-6 w-6 text-purple-600" />
|
<CheckCircle className="text-muted-foreground-foreground h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-semibold">Validation</h4>
|
<h4 className="font-semibold">Validation</h4>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
|
|||||||
@@ -1,719 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import InvoiceForm from "~/components/forms/invoice-form";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
|
||||||
import { Label } from "~/components/ui/label";
|
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "~/components/ui/select";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "~/components/ui/alert-dialog";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { Separator } from "~/components/ui/separator";
|
|
||||||
import { DatePicker } from "~/components/ui/date-picker";
|
|
||||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Save,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
FileText,
|
|
||||||
Building,
|
|
||||||
User,
|
|
||||||
Loader2,
|
|
||||||
Send,
|
|
||||||
DollarSign,
|
|
||||||
Hash,
|
|
||||||
Edit3,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
interface InvoiceItem {
|
|
||||||
tempId: string;
|
|
||||||
date: Date;
|
|
||||||
description: string;
|
|
||||||
hours: number;
|
|
||||||
rate: number;
|
|
||||||
amount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InvoiceFormData {
|
|
||||||
invoiceNumber: string;
|
|
||||||
businessId: string | undefined;
|
|
||||||
clientId: string;
|
|
||||||
issueDate: Date;
|
|
||||||
dueDate: Date;
|
|
||||||
notes: string;
|
|
||||||
taxRate: number;
|
|
||||||
items: InvoiceItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function InvoiceItemCard({
|
|
||||||
item,
|
|
||||||
index,
|
|
||||||
onUpdate,
|
|
||||||
onDelete,
|
|
||||||
_isLast,
|
|
||||||
}: {
|
|
||||||
item: InvoiceItem;
|
|
||||||
index: number;
|
|
||||||
onUpdate: (
|
|
||||||
index: number,
|
|
||||||
field: keyof InvoiceItem,
|
|
||||||
value: string | number | Date,
|
|
||||||
) => void;
|
|
||||||
onDelete: (index: number) => void;
|
|
||||||
_isLast: boolean;
|
|
||||||
}) {
|
|
||||||
const handleFieldChange = (
|
|
||||||
field: keyof InvoiceItem,
|
|
||||||
value: string | number | Date,
|
|
||||||
) => {
|
|
||||||
onUpdate(index, field, value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="card-secondary">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Header with item number and delete */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground text-xs font-medium">
|
|
||||||
Item {index + 1}
|
|
||||||
</span>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-icon-red hover:text-error h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete Item</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Are you sure you want to delete this line item? This action
|
|
||||||
cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => onDelete(index)}
|
|
||||||
className="btn-danger"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<Textarea
|
|
||||||
value={item.description}
|
|
||||||
onChange={(e) => handleFieldChange("description", e.target.value)}
|
|
||||||
placeholder="Description of work..."
|
|
||||||
className="min-h-[48px] resize-none text-sm"
|
|
||||||
rows={1}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Date, Hours, Rate, Amount in compact grid */}
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm md:grid-cols-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs font-medium">Date</Label>
|
|
||||||
<DatePicker
|
|
||||||
date={item.date}
|
|
||||||
onDateChange={(date) =>
|
|
||||||
handleFieldChange("date", date ?? new Date())
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs font-medium">Hours</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={item.hours}
|
|
||||||
onChange={(value) => handleFieldChange("hours", value)}
|
|
||||||
min={0}
|
|
||||||
step={0.25}
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs font-medium">Rate</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={item.rate}
|
|
||||||
onChange={(value) => handleFieldChange("rate", value)}
|
|
||||||
min={0}
|
|
||||||
step={0.25}
|
|
||||||
placeholder="0.00"
|
|
||||||
prefix="$"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs font-medium">Amount</Label>
|
|
||||||
<div className="bg-muted/30 flex h-8 items-center rounded-md border px-2">
|
|
||||||
<span className="amount-primary">
|
|
||||||
${(item.hours * item.rate).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewInvoicePage() {
|
export default function NewInvoicePage() {
|
||||||
const router = useRouter();
|
return <InvoiceForm />;
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Initialize form data with defaults
|
|
||||||
const today = new Date();
|
|
||||||
const thirtyDaysFromNow = new Date(today);
|
|
||||||
thirtyDaysFromNow.setDate(today.getDate() + 30);
|
|
||||||
|
|
||||||
// Auto-generate invoice number
|
|
||||||
const generateInvoiceNumber = () => {
|
|
||||||
const date = new Date();
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
const timestamp = Date.now().toString().slice(-4);
|
|
||||||
return `INV-${year}${month}-${timestamp}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState<InvoiceFormData>({
|
|
||||||
invoiceNumber: generateInvoiceNumber(),
|
|
||||||
businessId: undefined,
|
|
||||||
clientId: "",
|
|
||||||
issueDate: today,
|
|
||||||
dueDate: thirtyDaysFromNow,
|
|
||||||
notes: "",
|
|
||||||
taxRate: 0,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
tempId: `item-${Date.now()}`,
|
|
||||||
date: today,
|
|
||||||
description: "",
|
|
||||||
hours: 0,
|
|
||||||
rate: 0,
|
|
||||||
amount: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Queries
|
|
||||||
const { data: clients, isLoading: clientsLoading } =
|
|
||||||
api.clients.getAll.useQuery();
|
|
||||||
const { data: businesses, isLoading: businessesLoading } =
|
|
||||||
api.businesses.getAll.useQuery();
|
|
||||||
|
|
||||||
// Set default business when data loads
|
|
||||||
useEffect(() => {
|
|
||||||
if (businesses && !formData.businessId) {
|
|
||||||
const defaultBusiness = businesses.find((b) => b.isDefault);
|
|
||||||
if (defaultBusiness) {
|
|
||||||
setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [businesses, formData.businessId]);
|
|
||||||
|
|
||||||
// Mutations
|
|
||||||
const createInvoice = api.invoices.create.useMutation({
|
|
||||||
onSuccess: (invoice) => {
|
|
||||||
toast.success("Invoice created successfully");
|
|
||||||
router.push(`/dashboard/invoices/${invoice.id}`);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message || "Failed to create invoice");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleItemUpdate = (
|
|
||||||
index: number,
|
|
||||||
field: keyof InvoiceItem,
|
|
||||||
value: string | number | Date,
|
|
||||||
) => {
|
|
||||||
const updatedItems = [...formData.items];
|
|
||||||
const currentItem = updatedItems[index];
|
|
||||||
if (currentItem) {
|
|
||||||
updatedItems[index] = { ...currentItem, [field]: value };
|
|
||||||
|
|
||||||
// Recalculate amount for hours or rate changes
|
|
||||||
if (field === "hours" || field === "rate") {
|
|
||||||
const updatedItem = updatedItems[index];
|
|
||||||
if (!updatedItem) return;
|
|
||||||
updatedItem.amount = updatedItem.hours * updatedItem.rate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormData({ ...formData, items: updatedItems });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleItemDelete = (index: number) => {
|
|
||||||
if (formData.items.length === 1) {
|
|
||||||
toast.error("At least one line item is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedItems = formData.items.filter((_, i) => i !== index);
|
|
||||||
setFormData({ ...formData, items: updatedItems });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddItem = () => {
|
|
||||||
const newItem: InvoiceItem = {
|
|
||||||
tempId: `item-${Date.now()}`,
|
|
||||||
date: new Date(),
|
|
||||||
description: "",
|
|
||||||
hours: 0,
|
|
||||||
rate: 0,
|
|
||||||
amount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
items: [...formData.items, newItem],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveDraft = async () => {
|
|
||||||
await handleSave("draft");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateInvoice = async () => {
|
|
||||||
await handleSave("sent");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (status: "draft" | "sent") => {
|
|
||||||
// Validation
|
|
||||||
if (!formData.clientId) {
|
|
||||||
toast.error("Please select a client");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.items.length === 0) {
|
|
||||||
toast.error("At least one line item is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if all items have required fields
|
|
||||||
const invalidItems = formData.items.some(
|
|
||||||
(item) => !item.description.trim() || item.hours <= 0 || item.rate <= 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (invalidItems) {
|
|
||||||
toast.error("All line items must have description, hours, and rate");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await createInvoice.mutateAsync({
|
|
||||||
...formData,
|
|
||||||
businessId: formData.businessId ?? undefined,
|
|
||||||
status,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateSubtotal = () => {
|
|
||||||
return formData.items.reduce((sum, item) => sum + item.amount, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateTax = () => {
|
|
||||||
return (calculateSubtotal() * formData.taxRate) / 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateTotal = () => {
|
|
||||||
return calculateSubtotal() + calculateTax();
|
|
||||||
};
|
|
||||||
|
|
||||||
const isFormValid = () => {
|
|
||||||
return (
|
|
||||||
formData.clientId &&
|
|
||||||
formData.items.length > 0 &&
|
|
||||||
formData.items.every(
|
|
||||||
(item) => item.description.trim() && item.hours > 0 && item.rate > 0,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (clientsLoading || businessesLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Create Invoice"
|
|
||||||
description="Loading form data..."
|
|
||||||
variant="gradient"
|
|
||||||
/>
|
|
||||||
<Card className="card-primary">
|
|
||||||
<CardContent className="flex items-center justify-center p-8">
|
|
||||||
<Loader2 className="text-icon-emerald h-8 w-8 animate-spin" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 pb-32">
|
|
||||||
<PageHeader
|
|
||||||
title="Create Invoice"
|
|
||||||
description="Fill out the details below to create a new invoice"
|
|
||||||
variant="gradient"
|
|
||||||
>
|
|
||||||
<Link href="/dashboard/invoices">
|
|
||||||
<Button variant="outline" size="sm" className="w-full md:w-auto">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
<span className="hidden md:inline">Back to Invoices</span>
|
|
||||||
<span className="md:hidden">Back</span>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Invoice Header */}
|
|
||||||
<Card className="card-primary">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="card-title-secondary">
|
|
||||||
<FileText className="text-icon-emerald h-5 w-5" />
|
|
||||||
Invoice Details
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium">Invoice Number</Label>
|
|
||||||
<div className="bg-muted/30 flex h-10 items-center rounded-md border px-3">
|
|
||||||
<Hash className="text-muted-foreground mr-2 h-4 w-4" />
|
|
||||||
<span className="font-mono text-sm font-medium">
|
|
||||||
{formData.invoiceNumber}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium">Issue Date *</Label>
|
|
||||||
<DatePicker
|
|
||||||
date={formData.issueDate}
|
|
||||||
onDateChange={(date) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
issueDate: date ?? new Date(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium">Due Date *</Label>
|
|
||||||
<DatePicker
|
|
||||||
date={formData.dueDate}
|
|
||||||
onDateChange={(date) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
dueDate: date ?? new Date(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Business & Client */}
|
|
||||||
<Card className="card-primary">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="card-title-secondary">
|
|
||||||
<Building className="text-icon-emerald h-5 w-5" />
|
|
||||||
Business & Client
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium">From Business</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Building className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Select
|
|
||||||
value={formData.businessId ?? ""}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
businessId: value || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="pl-9">
|
|
||||||
<SelectValue placeholder="Select business..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{businesses?.map((business) => (
|
|
||||||
<SelectItem key={business.id} value={business.id}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{business.name}</span>
|
|
||||||
{business.isDefault && (
|
|
||||||
<Badge className="badge-secondary text-xs">
|
|
||||||
Default
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{(!businesses || businesses.length === 0) && (
|
|
||||||
<p className="text-icon-red text-sm">
|
|
||||||
No businesses found.{" "}
|
|
||||||
<Link
|
|
||||||
href="/dashboard/businesses/new"
|
|
||||||
className="link-secondary"
|
|
||||||
>
|
|
||||||
Create one first
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium">Client *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Select
|
|
||||||
value={formData.clientId}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({ ...formData, clientId: value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="pl-9">
|
|
||||||
<SelectValue placeholder="Select client..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{clients?.map((client) => (
|
|
||||||
<SelectItem key={client.id} value={client.id}>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{client.name}</div>
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
{client.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{(!clients || clients.length === 0) && (
|
|
||||||
<p className="text-sm text-red-600">
|
|
||||||
No clients found.{" "}
|
|
||||||
<Link
|
|
||||||
href="/dashboard/clients/new"
|
|
||||||
className="underline hover:text-red-700"
|
|
||||||
>
|
|
||||||
Create one first
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Line Items */}
|
|
||||||
<Card className="card-primary">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Edit3 className="h-5 w-5 text-emerald-600" />
|
|
||||||
Line Items ({formData.items.length})
|
|
||||||
</CardTitle>
|
|
||||||
<Button
|
|
||||||
onClick={handleAddItem}
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 md:mr-2" />
|
|
||||||
<span className="hidden md:inline">Add Item</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{formData.items.map((item, index) => (
|
|
||||||
<InvoiceItemCard
|
|
||||||
key={item.tempId}
|
|
||||||
item={item}
|
|
||||||
index={index}
|
|
||||||
onUpdate={handleItemUpdate}
|
|
||||||
onDelete={handleItemDelete}
|
|
||||||
_isLast={index === formData.items.length - 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tax & Totals */}
|
|
||||||
<Card className="card-primary">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<DollarSign className="h-5 w-5 text-emerald-600" />
|
|
||||||
Tax & Totals
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2 md:col-span-1">
|
|
||||||
<Label className="text-sm font-medium">Tax Rate (%)</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={formData.taxRate}
|
|
||||||
onChange={(value) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
taxRate: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={0.01}
|
|
||||||
placeholder="0.00"
|
|
||||||
suffix="%"
|
|
||||||
width="full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium">Notes</Label>
|
|
||||||
<Textarea
|
|
||||||
value={formData.notes}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, notes: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="Payment terms, additional notes..."
|
|
||||||
rows={4}
|
|
||||||
className="resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-muted/20 rounded-lg border p-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Subtotal:</span>
|
|
||||||
<span className="font-mono font-medium">
|
|
||||||
${calculateSubtotal().toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Tax ({formData.taxRate}%):
|
|
||||||
</span>
|
|
||||||
<span className="font-mono font-medium">
|
|
||||||
${calculateTax().toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="flex justify-between text-lg font-bold">
|
|
||||||
<span>Total:</span>
|
|
||||||
<span className="font-mono text-emerald-600">
|
|
||||||
${calculateTotal().toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FloatingActionBar
|
|
||||||
leftContent={
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
|
||||||
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Creating a new invoice
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Complete the form to create your invoice
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Link href="/dashboard/invoices">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="border-border/40 hover:bg-accent/50"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4 md:mr-2" />
|
|
||||||
<span className="hidden md:inline">Cancel</span>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
onClick={handleSaveDraft}
|
|
||||||
disabled={isLoading || !isFormValid()}
|
|
||||||
variant="outline"
|
|
||||||
className="border-border/40 hover:bg-accent/50"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin md:mr-2" />
|
|
||||||
) : (
|
|
||||||
<Save className="h-4 w-4 md:mr-2" />
|
|
||||||
)}
|
|
||||||
<span className="hidden md:inline">Save Draft</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateInvoice}
|
|
||||||
disabled={isLoading || !isFormValid()}
|
|
||||||
className="btn-brand-primary shadow-md"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin md:mr-2" />
|
|
||||||
) : (
|
|
||||||
<Send className="h-4 w-4 md:mr-2" />
|
|
||||||
)}
|
|
||||||
<span className="hidden md:inline">Create Invoice</span>
|
|
||||||
</Button>
|
|
||||||
</FloatingActionBar>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,19 +16,19 @@ async function InvoicesTable() {
|
|||||||
|
|
||||||
export default async function InvoicesPage() {
|
export default async function InvoicesPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="page-enter space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Invoices"
|
title="Invoices"
|
||||||
description="Manage your invoices and track payments"
|
description="Manage your invoices and track payments"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<Button asChild variant="outline" className="shadow-sm">
|
<Button asChild variant="outline" className="hover-lift shadow-sm">
|
||||||
<Link href="/dashboard/invoices/import">
|
<Link href="/dashboard/invoices/import">
|
||||||
<Upload className="mr-2 h-5 w-5" />
|
<Upload className="mr-2 h-5 w-5" />
|
||||||
<span>Import CSV</span>
|
<span>Import CSV</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild className="btn-brand-primary shadow-md">
|
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
<span>Create Invoice</span>
|
<span>Create Invoice</span>
|
||||||
@@ -41,6 +41,6 @@ export default async function InvoicesPage() {
|
|||||||
<InvoicesTable />
|
<InvoicesTable />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api, type RouterOutputs } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
type InvoiceTemplate = RouterOutputs["invoiceTemplates"]["getAll"][number];
|
||||||
|
|
||||||
|
interface TemplateListProps {
|
||||||
|
items: InvoiceTemplate[];
|
||||||
|
type: "notes" | "terms";
|
||||||
|
isLoading: boolean;
|
||||||
|
onCreate: (type: "notes" | "terms") => void;
|
||||||
|
onEdit: (template: InvoiceTemplate) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateList({
|
||||||
|
items,
|
||||||
|
type,
|
||||||
|
isLoading,
|
||||||
|
onCreate,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: TemplateListProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => onCreate(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((template) => (
|
||||||
|
<Card key={template.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">{template.name}</p>
|
||||||
|
{template.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">
|
||||||
|
{template.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => onEdit(template)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive h-8 w-8 p-0"
|
||||||
|
onClick={() => onDelete(template.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: InvoiceTemplate) => {
|
||||||
|
setEditId(t.id);
|
||||||
|
setForm({
|
||||||
|
name: t.name,
|
||||||
|
type: t.type as "notes" | "terms",
|
||||||
|
content: t.content,
|
||||||
|
isDefault: t.isDefault,
|
||||||
|
});
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
toast.error("Name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.content.trim()) {
|
||||||
|
toast.error("Content is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editId) update.mutate({ id: editId, ...form });
|
||||||
|
else create.mutate(form);
|
||||||
|
};
|
||||||
|
|
||||||
|
const notesTemplates = templates.filter((t) => t.type === "notes");
|
||||||
|
const termsTemplates = templates.filter((t) => t.type === "terms");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCreate={handleOpen}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={setDeleteId}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="terms" className="mt-4">
|
||||||
|
<TemplateList
|
||||||
|
items={termsTemplates}
|
||||||
|
type="terms"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCreate={handleOpen}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={setDeleteId}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +1,9 @@
|
|||||||
import { Navbar } from "~/components/layout/navbar";
|
import { DashboardShell } from "~/components/layout/dashboard-shell";
|
||||||
import { Sidebar } from "~/components/layout/sidebar";
|
|
||||||
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
|
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return <DashboardShell>{children}</DashboardShell>;
|
||||||
<div className="floating-orbs relative min-h-screen">
|
|
||||||
<Navbar />
|
|
||||||
<Sidebar />
|
|
||||||
{/* Mobile layout - no left margin */}
|
|
||||||
<main className="relative z-10 min-h-screen pt-20 md:hidden">
|
|
||||||
<div className="px-4 pt-4 pb-6 sm:px-6">
|
|
||||||
<DashboardBreadcrumbs />
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{/* Desktop layout - with sidebar margin */}
|
|
||||||
<main className="relative z-10 hidden min-h-screen pt-20 md:ml-[276px] md:block">
|
|
||||||
<div className="px-6 pt-6 pb-6">
|
|
||||||
<DashboardBreadcrumbs />
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+315
-235
@@ -1,193 +1,211 @@
|
|||||||
import { Suspense } from "react";
|
|
||||||
import { HydrateClient, api } from "~/trpc/server";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
|
||||||
import { auth } from "~/server/auth";
|
|
||||||
import Link from "next/link";
|
|
||||||
import {
|
import {
|
||||||
Users,
|
|
||||||
FileText,
|
|
||||||
DollarSign,
|
|
||||||
TrendingUp,
|
|
||||||
Plus,
|
|
||||||
ArrowUpRight,
|
|
||||||
Calendar,
|
|
||||||
Clock,
|
|
||||||
Eye,
|
|
||||||
Edit,
|
|
||||||
Activity,
|
Activity,
|
||||||
|
ArrowUpRight,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Calendar,
|
||||||
|
Edit,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
Plus,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
|
import { auth } from "~/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { HydrateClient, api } from "~/trpc/server";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
import { RevenueChart } from "~/app/dashboard/_components/revenue-chart";
|
||||||
|
import { InvoiceStatusChart } from "~/app/dashboard/_components/invoice-status-chart";
|
||||||
|
import { MonthlyMetricsChart } from "~/app/dashboard/_components/monthly-metrics-chart";
|
||||||
|
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
|
||||||
|
import type { DashboardStats, RecentInvoice } from "./types";
|
||||||
|
|
||||||
// Modern gradient background component
|
// Hero section with clean mono design
|
||||||
function DashboardHero({ firstName }: { firstName: string }) {
|
|
||||||
return (
|
|
||||||
<Card className="relative mb-8 overflow-hidden p-8 border-0 shadow-sm transition-shadow hover:shadow-md">
|
|
||||||
<div className="absolute inset-0" />
|
|
||||||
<div className="relative z-10">
|
|
||||||
<h1 className="mb-2 text-3xl font-bold">Welcome back, {firstName}!</h1>
|
|
||||||
<p className="text-lg">
|
|
||||||
Ready to manage your invoicing business
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="absolute -top-8 -right-8 h-32 w-32 rounded-full bg-white/10" />
|
|
||||||
<div className="absolute -right-4 -bottom-4 h-24 w-24 rounded-full bg-white/5" />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced stats cards with better visual hierarchy
|
|
||||||
async function DashboardStats() {
|
|
||||||
const [clients, invoices] = await Promise.all([
|
|
||||||
api.clients.getAll(),
|
|
||||||
api.invoices.getAll(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const totalClients = clients.length;
|
// Enhanced stats cards with better visuals
|
||||||
const totalInvoices = invoices.length;
|
function DashboardStats({ stats }: { stats: DashboardStats }) { // TODO: Import RouterOutput type
|
||||||
const totalRevenue = invoices
|
const formatTrend = (value: number, isCount = false) => {
|
||||||
.filter((invoice) => invoice.status === "paid")
|
if (isCount) {
|
||||||
.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
return value > 0 ? `+${value}` : value.toString();
|
||||||
const pendingAmount = invoices
|
}
|
||||||
.filter((invoice) => invoice.status === "sent")
|
return value > 0 ? `+${value.toFixed(1)}%` : `${value.toFixed(1)}%`;
|
||||||
.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
};
|
||||||
|
|
||||||
const stats = [
|
const statCards = [
|
||||||
{
|
{
|
||||||
title: "Total Revenue",
|
title: "Total Revenue",
|
||||||
value: `$${totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
value: `$${stats.totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||||
change: "+12.5%",
|
numericValue: stats.totalRevenue,
|
||||||
icon: DollarSign,
|
isCurrency: true,
|
||||||
color: "",
|
change: formatTrend(stats.revenueChange),
|
||||||
bgColor: "bg-green-50",
|
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const),
|
||||||
changeColor: "",
|
iconName: "DollarSign" as const,
|
||||||
|
description: "Total collected revenue",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Pending Amount",
|
title: "Pending Amount",
|
||||||
value: `$${pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||||
change: "+8.2%",
|
numericValue: stats.pendingAmount,
|
||||||
icon: Clock,
|
isCurrency: true,
|
||||||
color: "",
|
change: "0%", // TODO: Calculate pending change if needed
|
||||||
bgColor: "bg-amber-50",
|
trend: "neutral" as const,
|
||||||
changeColor: "",
|
iconName: "Clock" as const,
|
||||||
|
description: "Invoices awaiting payment",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Active Clients",
|
title: "Active Clients",
|
||||||
value: totalClients.toString(),
|
value: stats.totalClients.toString(),
|
||||||
change: "+3",
|
numericValue: stats.totalClients,
|
||||||
icon: Users,
|
isCurrency: false,
|
||||||
color: "",
|
change: "0", // TODO: Calculate client change if needed
|
||||||
bgColor: "bg-blue-50",
|
trend: "neutral" as const,
|
||||||
changeColor: "",
|
iconName: "Users" as const,
|
||||||
|
description: "Total registered clients",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Total Invoices",
|
title: "Overdue Invoices",
|
||||||
value: totalInvoices.toString(),
|
value: stats.overdueCount.toString(),
|
||||||
change: "+15",
|
numericValue: stats.overdueCount,
|
||||||
icon: FileText,
|
isCurrency: false,
|
||||||
color: "",
|
change: "0", // TODO: Calculate overdue change if needed
|
||||||
bgColor: "bg-purple-50",
|
trend: "neutral" as const,
|
||||||
changeColor: "",
|
iconName: "TrendingDown" as const,
|
||||||
|
description: "Invoices past due date",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8 grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{stats.map((stat) => {
|
{statCards.map((stat, index) => (
|
||||||
const Icon = stat.icon;
|
<AnimatedStatsCard
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={stat.title}
|
key={stat.title}
|
||||||
className="border-0 shadow-sm transition-shadow hover:shadow-md"
|
title={stat.title}
|
||||||
>
|
value={stat.value}
|
||||||
<CardContent className="p-3 sm:p-4 lg:p-6">
|
numericValue={stat.numericValue}
|
||||||
<div className="mb-2 flex items-center justify-between sm:mb-3 lg:mb-4">
|
isCurrency={stat.isCurrency}
|
||||||
<div className={`rounded-lg p-1.5 sm:p-2 ${stat.bgColor}`}>
|
iconName={stat.iconName}
|
||||||
<Icon className="h-3 w-3 text-gray-700 sm:h-4 sm:w-4 lg:h-5 lg:w-5 dark:text-gray-800" />
|
change={stat.change}
|
||||||
</div>
|
trend={stat.trend}
|
||||||
<span className="text-xs font-medium text-teal-600 dark:text-teal-400">
|
description={stat.description}
|
||||||
{stat.change}
|
delay={index * 100}
|
||||||
</span>
|
/>
|
||||||
</div>
|
))}
|
||||||
<div>
|
|
||||||
<p className="mb-1 text-base font-bold text-gray-900 sm:text-xl lg:text-2xl dark:text-gray-100">
|
|
||||||
{stat.value}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-600 lg:text-sm dark:text-gray-300">
|
|
||||||
{stat.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick Actions with better visual design
|
// Charts section
|
||||||
|
async function ChartsSection({ stats }: { stats: DashboardStats }) {
|
||||||
|
// We still fetch all invoices for the status chart for now, or we could aggregate that too.
|
||||||
|
// For now, let's keep status chart as is (fetching all) but use aggregated for revenue.
|
||||||
|
// Actually, let's fetch invoices here for the status chart to keep it working.
|
||||||
|
const invoices = await api.invoices.getAll();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
{/* Revenue Trend Chart */}
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
Revenue Over Time
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RevenueChart data={stats.revenueChartData} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Invoice Status Breakdown */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Activity className="h-5 w-5" />
|
||||||
|
Invoice Status
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<InvoiceStatusChart invoices={invoices} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Monthly Metrics */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5" />
|
||||||
|
Monthly Metrics
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<MonthlyMetricsChart invoices={invoices} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced Quick Actions
|
||||||
function QuickActions() {
|
function QuickActions() {
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
title: "Create Invoice",
|
title: "Create Invoice",
|
||||||
description: "Start a new invoice",
|
description: "Start a new invoice for a client",
|
||||||
href: "/dashboard/invoices/new",
|
href: "/dashboard/invoices/new",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
primary: true,
|
featured: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Add Client",
|
title: "Add Client",
|
||||||
description: "Add a new client",
|
description: "Register a new client",
|
||||||
href: "/dashboard/clients/new",
|
href: "/dashboard/clients/new",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
primary: false,
|
featured: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "View Reports",
|
title: "View All Invoices",
|
||||||
description: "Business analytics",
|
description: "Manage your invoice pipeline",
|
||||||
href: "/dashboard/reports",
|
href: "/dashboard/invoices",
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
primary: false,
|
featured: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 shadow-sm">
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-lg">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Plus className="h-5 w-5 text-teal-600 dark:text-teal-400" />
|
<Plus className="h-5 w-5" />
|
||||||
Quick Actions
|
Quick Actions
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-3">
|
||||||
{actions.map((action) => {
|
{actions.map((action) => {
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Link
|
||||||
key={action.title}
|
key={action.title}
|
||||||
asChild
|
href={action.href}
|
||||||
variant={action.primary ? "default" : "outline"}
|
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${action.featured
|
||||||
className={`h-12 w-full justify-start px-3 ${
|
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
||||||
action.primary
|
: "border-border bg-background hover:bg-muted/50"
|
||||||
? "bg-teal-600 text-white hover:bg-teal-700"
|
|
||||||
: "border-gray-200 hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Link href={action.href}>
|
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||||
<div className="flex items-center gap-3">
|
<div className="min-w-0 flex-1">
|
||||||
<Icon
|
<p className="font-semibold">{action.title}</p>
|
||||||
className={`h-4 w-4 ${action.primary ? "text-white" : "text-gray-600 dark:text-gray-300"}`}
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||||
/>
|
{action.description}
|
||||||
<span
|
</p>
|
||||||
className={`font-medium ${action.primary ? "text-white" : "text-gray-900 dark:text-gray-100"}`}
|
|
||||||
>
|
|
||||||
{action.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -195,30 +213,35 @@ function QuickActions() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current work in progress
|
// Current work section with enhanced design
|
||||||
async function CurrentWork() {
|
async function CurrentWork() {
|
||||||
const invoices = await api.invoices.getAll();
|
const invoices = await api.invoices.getAll();
|
||||||
const draftInvoices = invoices.filter(
|
const draftInvoices = invoices.filter(
|
||||||
(invoice) => invoice.status === "draft",
|
(invoice) =>
|
||||||
|
getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
) === "draft",
|
||||||
);
|
);
|
||||||
const currentInvoice = draftInvoices[0];
|
const currentInvoice = draftInvoices[0];
|
||||||
|
|
||||||
if (!currentInvoice) {
|
if (!currentInvoice) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 shadow-sm">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-lg">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Activity className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<Activity className="h-5 w-5" />
|
||||||
Current Work
|
Current Work
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="py-8 text-center">
|
<div className="py-8 text-center">
|
||||||
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-300 dark:text-gray-600" />
|
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||||
<p className="mb-4 text-gray-600 dark:text-gray-300">
|
<h3 className="mb-2 text-lg font-semibold">No active drafts</h3>
|
||||||
No draft invoices found
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Create a new invoice to get started
|
||||||
</p>
|
</p>
|
||||||
<Button asChild className="bg-teal-600 hover:bg-teal-700">
|
<Button asChild variant="outline" className="border-foreground/20">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create Invoice
|
Create Invoice
|
||||||
@@ -234,49 +257,48 @@ async function CurrentWork() {
|
|||||||
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
|
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 shadow-sm">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-lg">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Activity className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<Activity className="h-5 w-5" />
|
||||||
Current Work
|
Current Work
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant="secondary">In Progress</Badge>
|
<Badge variant="secondary">In Progress</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-2">
|
||||||
<div>
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold break-words">
|
||||||
#{currentInvoice.invoiceNumber}
|
#{currentInvoice.invoiceNumber}
|
||||||
</p>
|
</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
<span className="text-primary text-2xl font-bold">
|
||||||
{currentInvoice.client?.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-2xl font-bold text-teal-600 dark:text-teal-400">
|
|
||||||
${currentInvoice.totalAmount.toFixed(2)}
|
${currentInvoice.totalAmount.toFixed(2)}
|
||||||
</p>
|
</span>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
</div>
|
||||||
{totalHours.toFixed(1)} hours
|
<div className="text-muted-foreground flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||||
</p>
|
<span className="break-words">{currentInvoice.client?.name}</span>
|
||||||
|
<span className="text-xs sm:text-sm">
|
||||||
|
{totalHours.toFixed(1)} hours logged
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button asChild variant="outline" size="sm" className="flex-1">
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="hover-lift flex-1"
|
||||||
|
>
|
||||||
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
||||||
<Eye className="mr-2 h-3 w-3" />
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
View
|
View
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button asChild size="sm" className="hover-lift flex-1">
|
||||||
asChild
|
|
||||||
size="sm"
|
|
||||||
className="flex-1 bg-teal-600 hover:bg-teal-700"
|
|
||||||
>
|
|
||||||
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
||||||
<Edit className="mr-2 h-3 w-3" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Continue
|
Continue
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -287,51 +309,62 @@ async function CurrentWork() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent activity with enhanced design
|
// Enhanced recent activity
|
||||||
async function RecentActivity() {
|
async function RecentActivity({ recentInvoices }: { recentInvoices: RecentInvoice[] }) {
|
||||||
const invoices = await api.invoices.getAll();
|
// Use passed recentInvoices instead of fetching all
|
||||||
const recentInvoices = invoices
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.issueDate).getTime() - new Date(a.issueDate).getTime(),
|
|
||||||
)
|
|
||||||
.slice(0, 5);
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusStyle = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "paid":
|
case "paid":
|
||||||
return "bg-green-50 border-green-200";
|
return {
|
||||||
|
backgroundColor: "oklch(var(--chart-2) / 0.1)",
|
||||||
|
borderColor: "oklch(var(--chart-2) / 0.3)",
|
||||||
|
color: "oklch(var(--chart-2))",
|
||||||
|
};
|
||||||
case "sent":
|
case "sent":
|
||||||
return "bg-blue-50 border-blue-200";
|
return {
|
||||||
|
backgroundColor: "oklch(var(--chart-1) / 0.1)",
|
||||||
|
borderColor: "oklch(var(--chart-1) / 0.3)",
|
||||||
|
color: "oklch(var(--chart-1))",
|
||||||
|
};
|
||||||
case "overdue":
|
case "overdue":
|
||||||
return "bg-red-50 border-red-200";
|
return {
|
||||||
|
backgroundColor: "oklch(var(--chart-3) / 0.1)",
|
||||||
|
borderColor: "oklch(var(--chart-3) / 0.3)",
|
||||||
|
color: "oklch(var(--chart-3))",
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return "bg-gray-50 border-gray-200";
|
return {
|
||||||
|
backgroundColor: "hsl(var(--muted))",
|
||||||
|
borderColor: "hsl(var(--border))",
|
||||||
|
color: "hsl(var(--muted-foreground))",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 shadow-sm">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-lg">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Calendar className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
<Calendar className="h-5 w-5" />
|
||||||
Recent Activity
|
Recent Activity
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/dashboard/invoices">
|
<Link href="/dashboard/invoices">
|
||||||
View All
|
<span className="hidden sm:inline">View All</span>
|
||||||
<ArrowUpRight className="ml-1 h-4 w-4" />
|
<ArrowUpRight className="h-4 w-4 sm:ml-1" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{recentInvoices.length === 0 ? (
|
{recentInvoices.length === 0 ? (
|
||||||
<div className="py-8 text-center">
|
<div className="py-8 text-center">
|
||||||
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-300 dark:text-gray-600" />
|
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||||
<p className="mb-4 text-gray-600 dark:text-gray-300">
|
<h3 className="mb-2 text-lg font-semibold">No invoices yet</h3>
|
||||||
No invoices yet
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Create your first invoice to get started
|
||||||
</p>
|
</p>
|
||||||
<Button asChild className="bg-teal-600 hover:bg-teal-700">
|
<Button asChild variant="outline" className="border-foreground/20">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create Your First Invoice
|
Create Your First Invoice
|
||||||
@@ -340,45 +373,42 @@ async function RecentActivity() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{recentInvoices.map((invoice) => (
|
{recentInvoices.map((invoice, _index) => (
|
||||||
<Link
|
<Link
|
||||||
key={invoice.id}
|
key={invoice.id}
|
||||||
href={`/dashboard/invoices/${invoice.id}`}
|
href={`/dashboard/invoices/${invoice.id}`}
|
||||||
className="block"
|
className="block"
|
||||||
>
|
>
|
||||||
<Card className="card-secondary transition-colors hover:bg-gray-200/70 dark:hover:bg-gray-700/60">
|
<div className="recent-activity-item bg-muted/50 hover:bg-muted border-foreground/20 rounded-lg border p-3 transition-colors">
|
||||||
<CardContent className="p-4">
|
<div className="flex items-start gap-3">
|
||||||
<div className="space-y-3">
|
<div className="bg-muted flex-shrink-0 rounded-lg p-2">
|
||||||
<div className="flex items-center gap-3">
|
<FileText className="text-muted-foreground h-4 w-4" />
|
||||||
<div className="rounded-lg bg-gray-100 p-2 dark:bg-gray-700">
|
|
||||||
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-300" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">
|
||||||
#{invoice.invoiceNumber}
|
#{invoice.invoiceNumber}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
<p className="text-muted-foreground truncate text-sm">
|
||||||
{invoice.client?.name} •{" "}
|
{invoice.client?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
<Badge style={getStatusStyle(invoice.status)}>
|
||||||
|
{invoice.status}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-primary font-semibold">
|
||||||
|
${invoice.totalAmount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
{new Date(invoice.issueDate).toLocaleDateString()}
|
{new Date(invoice.issueDate).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg p-1 transition-colors hover:bg-gray-300/50 dark:hover:bg-gray-600/50">
|
|
||||||
<Eye className="h-4 w-4 text-gray-600 dark:text-gray-300" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Badge
|
|
||||||
className={`border ${getStatusColor(invoice.status)}`}
|
|
||||||
>
|
|
||||||
{invoice.status}
|
|
||||||
</Badge>
|
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
${invoice.totalAmount.toFixed(2)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -391,16 +421,16 @@ async function RecentActivity() {
|
|||||||
// Loading skeletons
|
// Loading skeletons
|
||||||
function StatsSkeleton() {
|
function StatsSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="mb-8 grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<Card key={i} className="border-0 shadow-sm">
|
<Card key={i}>
|
||||||
<CardContent className="p-3 sm:p-4 lg:p-6">
|
<CardContent className="p-6">
|
||||||
<div className="mb-2 flex items-center justify-between sm:mb-3 lg:mb-4">
|
<div className="flex items-center justify-between space-y-0 pb-2">
|
||||||
<Skeleton className="h-6 w-6 rounded-lg sm:h-8 sm:w-8 lg:h-9 lg:w-9" />
|
<Skeleton className="h-4 w-24" />
|
||||||
<Skeleton className="h-3 w-8 sm:h-4 sm:w-12" />
|
<Skeleton className="h-4 w-12" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="mb-1 h-5 w-16 sm:mb-2 sm:h-6 sm:w-20 lg:h-8" />
|
<Skeleton className="mb-2 h-8 w-20" />
|
||||||
<Skeleton className="h-3 w-20 sm:h-4 sm:w-24" />
|
<Skeleton className="h-3 w-32" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -408,9 +438,40 @@ function StatsSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChartsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-40" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-36" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CardSkeleton() {
|
function CardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 shadow-sm">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -425,35 +486,54 @@ function CardSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
|
||||||
|
// ... imports
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await auth();
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
||||||
|
|
||||||
|
// Fetch stats centrally
|
||||||
|
const stats = await api.dashboard.getStats();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="page-enter space-y-6">
|
||||||
<DashboardHero firstName={firstName} />
|
<DashboardPageHeader
|
||||||
|
title={`Welcome back, ${firstName}!`}
|
||||||
|
description="Here's what's happening with your business today"
|
||||||
|
/>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<Suspense fallback={<StatsSkeleton />}>
|
<Suspense fallback={<StatsSkeleton />}>
|
||||||
<DashboardStats />
|
<DashboardStats stats={stats} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
<HydrateClient>
|
||||||
|
<Suspense fallback={<ChartsSkeleton />}>
|
||||||
|
<ChartsSection stats={stats} />
|
||||||
|
</Suspense>
|
||||||
|
</HydrateClient>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<div className="space-y-6">
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<Suspense fallback={<CardSkeleton />}>
|
<Suspense fallback={<CardSkeleton />}>
|
||||||
<CurrentWork />
|
<CurrentWork />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
|
|
||||||
<QuickActions />
|
<QuickActions />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<Suspense fallback={<CardSkeleton />}>
|
<Suspense fallback={<CardSkeleton />}>
|
||||||
<RecentActivity />
|
<RecentActivity recentInvoices={stats.recentInvoices} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,847 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { StatusBadge } from "~/components/data/status-badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
|
import { formatCurrency } from "~/lib/currency";
|
||||||
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Download,
|
||||||
|
Receipt,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
function toNumericChartValue(value: unknown) {
|
||||||
|
const numericValue = typeof value === "number" ? value : Number(value ?? 0);
|
||||||
|
return Number.isFinite(numericValue) ? numericValue : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportsPage() {
|
||||||
|
const { data: invoices = [], isLoading: invoicesLoading } =
|
||||||
|
api.invoices.getAll.useQuery();
|
||||||
|
const { data: expenses = [], isLoading: expensesLoading } =
|
||||||
|
api.expenses.getAll.useQuery();
|
||||||
|
const { data: stats } = api.dashboard.getStats.useQuery();
|
||||||
|
|
||||||
|
const isLoading = invoicesLoading || expensesLoading;
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const [taxYear, setTaxYear] = useState(String(currentYear));
|
||||||
|
|
||||||
|
// Overview data (last 12 months)
|
||||||
|
const overviewData = useMemo(() => {
|
||||||
|
if (!invoices.length) return null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const monthMap: Record<string, number> = {};
|
||||||
|
for (let i = 11; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
monthMap[key] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalRevenue = 0;
|
||||||
|
let totalPending = 0;
|
||||||
|
let totalHours = 0;
|
||||||
|
|
||||||
|
for (const inv of invoices) {
|
||||||
|
const status = getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
);
|
||||||
|
if (status === "paid") {
|
||||||
|
totalRevenue += inv.totalAmount;
|
||||||
|
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount;
|
||||||
|
} else if (status === "sent" || status === "overdue") {
|
||||||
|
totalPending += inv.totalAmount;
|
||||||
|
}
|
||||||
|
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
|
||||||
|
month: new Date(month + "-01").toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
}),
|
||||||
|
revenue,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
||||||
|
for (const inv of invoices) {
|
||||||
|
const status = getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
);
|
||||||
|
if (status === "paid" && inv.client) {
|
||||||
|
const id = inv.client.id;
|
||||||
|
const entry = (clientMap[id] ??= {
|
||||||
|
name: inv.client.name,
|
||||||
|
revenue: 0,
|
||||||
|
});
|
||||||
|
entry.revenue += inv.totalAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const topClients = Object.values(clientMap)
|
||||||
|
.sort((a, b) => b.revenue - a.revenue)
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
const statusCount: Record<string, number> = {
|
||||||
|
draft: 0,
|
||||||
|
sent: 0,
|
||||||
|
paid: 0,
|
||||||
|
overdue: 0,
|
||||||
|
};
|
||||||
|
for (const inv of invoices) {
|
||||||
|
const s = getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
);
|
||||||
|
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
revenueByMonth,
|
||||||
|
topClients,
|
||||||
|
totalRevenue,
|
||||||
|
totalPending,
|
||||||
|
totalHours,
|
||||||
|
statusCount,
|
||||||
|
};
|
||||||
|
}, [invoices]);
|
||||||
|
|
||||||
|
// Tax summary for selected year
|
||||||
|
const taxData = useMemo(() => {
|
||||||
|
const year = parseInt(taxYear);
|
||||||
|
|
||||||
|
const yearInvoices = invoices.filter((inv) => {
|
||||||
|
const status = getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
status === "paid" && new Date(inv.issueDate).getFullYear() === year
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const yearExpenses = expenses.filter(
|
||||||
|
(exp) => new Date(exp.date).getFullYear() === year,
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSubtotal = (inv: (typeof yearInvoices)[number]) => {
|
||||||
|
const itemSubtotal = (inv.items ?? []).reduce(
|
||||||
|
(s, item) => s + item.amount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (itemSubtotal > 0) return itemSubtotal;
|
||||||
|
|
||||||
|
const taxMultiplier = 1 + (inv.taxRate ?? 0) / 100;
|
||||||
|
return taxMultiplier > 0
|
||||||
|
? inv.totalAmount / taxMultiplier
|
||||||
|
: inv.totalAmount;
|
||||||
|
};
|
||||||
|
|
||||||
|
const grossIncome = yearInvoices.reduce(
|
||||||
|
(s, inv) => s + getSubtotal(inv),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const taxCollected = yearInvoices.reduce(
|
||||||
|
(s, inv) => s + (inv.totalAmount - getSubtotal(inv)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0);
|
||||||
|
const deductibleExpenses = yearExpenses
|
||||||
|
.filter(
|
||||||
|
(exp) =>
|
||||||
|
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible,
|
||||||
|
)
|
||||||
|
.reduce((s, exp) => s + exp.amount, 0);
|
||||||
|
|
||||||
|
const netProfit = grossIncome - deductibleExpenses;
|
||||||
|
const seTaxBase = Math.max(0, netProfit) * 0.9235;
|
||||||
|
const selfEmploymentTax = seTaxBase * 0.153;
|
||||||
|
const taxableIncome = Math.max(0, netProfit - selfEmploymentTax / 2);
|
||||||
|
const federalEstimate = taxableIncome * 0.22;
|
||||||
|
const totalEstimated = selfEmploymentTax + federalEstimate;
|
||||||
|
|
||||||
|
const quarters = [1, 2, 3, 4].map((q) => {
|
||||||
|
const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2];
|
||||||
|
return {
|
||||||
|
label: `Q${q}`,
|
||||||
|
income: yearInvoices
|
||||||
|
.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth()))
|
||||||
|
.reduce((s, inv) => s + getSubtotal(inv), 0),
|
||||||
|
expenses: yearExpenses
|
||||||
|
.filter((exp) => qMonths.includes(new Date(exp.date).getMonth()))
|
||||||
|
.reduce((s, exp) => s + exp.amount, 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
grossIncome,
|
||||||
|
taxCollected,
|
||||||
|
totalInvoiced: grossIncome + taxCollected,
|
||||||
|
totalExpenses,
|
||||||
|
deductibleExpenses,
|
||||||
|
netProfit,
|
||||||
|
selfEmploymentTax,
|
||||||
|
federalEstimate,
|
||||||
|
totalEstimated,
|
||||||
|
quarters,
|
||||||
|
yearInvoices,
|
||||||
|
yearExpenses,
|
||||||
|
};
|
||||||
|
}, [invoices, expenses, taxYear]);
|
||||||
|
|
||||||
|
const availableYears = useMemo(() => {
|
||||||
|
const years = new Set<number>([currentYear, currentYear - 1]);
|
||||||
|
for (const inv of invoices)
|
||||||
|
years.add(new Date(inv.issueDate).getFullYear());
|
||||||
|
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
||||||
|
return Array.from(years).sort((a, b) => b - a);
|
||||||
|
}, [invoices, expenses, currentYear]);
|
||||||
|
|
||||||
|
const avgInvoice =
|
||||||
|
invoices.length > 0
|
||||||
|
? (overviewData?.totalRevenue ?? 0) /
|
||||||
|
(invoices.filter(
|
||||||
|
(i) =>
|
||||||
|
getEffectiveInvoiceStatus(
|
||||||
|
i.status as StoredInvoiceStatus,
|
||||||
|
i.dueDate,
|
||||||
|
) === "paid",
|
||||||
|
).length || 1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
function exportCSV() {
|
||||||
|
const rows: string[] = [
|
||||||
|
`Tax Year ${taxYear} - Income & Expense Report`,
|
||||||
|
`Generated: ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`,
|
||||||
|
"",
|
||||||
|
"INCOME (Paid Invoices)",
|
||||||
|
"Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total",
|
||||||
|
...taxData.yearInvoices.map((inv) => {
|
||||||
|
const subtotal = (inv.items ?? []).reduce(
|
||||||
|
(s, item) => s + item.amount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const fallbackSubtotal =
|
||||||
|
inv.totalAmount / (1 + (inv.taxRate ?? 0) / 100);
|
||||||
|
const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal;
|
||||||
|
const taxAmt = inv.totalAmount - invoiceSubtotal;
|
||||||
|
return [
|
||||||
|
new Date(inv.issueDate).toLocaleDateString("en-US"),
|
||||||
|
inv.invoiceNumber,
|
||||||
|
`"${inv.client?.name ?? ""}"`,
|
||||||
|
invoiceSubtotal.toFixed(2),
|
||||||
|
`${(inv.taxRate ?? 0).toFixed(1)}%`,
|
||||||
|
taxAmt.toFixed(2),
|
||||||
|
inv.totalAmount.toFixed(2),
|
||||||
|
].join(",");
|
||||||
|
}),
|
||||||
|
`,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`,
|
||||||
|
"",
|
||||||
|
"EXPENSES",
|
||||||
|
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
|
||||||
|
...taxData.yearExpenses.map((exp) =>
|
||||||
|
[
|
||||||
|
new Date(exp.date).toLocaleDateString("en-US"),
|
||||||
|
`"${exp.description}"`,
|
||||||
|
`"${exp.category ?? ""}"`,
|
||||||
|
exp.amount.toFixed(2),
|
||||||
|
exp.currency,
|
||||||
|
exp.billable ? "Yes" : "No",
|
||||||
|
exp.reimbursable ? "Yes" : "No",
|
||||||
|
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible
|
||||||
|
? "Yes"
|
||||||
|
: "No",
|
||||||
|
].join(","),
|
||||||
|
),
|
||||||
|
`,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`,
|
||||||
|
"",
|
||||||
|
"TAX SUMMARY",
|
||||||
|
`Gross Income,${taxData.grossIncome.toFixed(2)}`,
|
||||||
|
`Tax Collected,${taxData.taxCollected.toFixed(2)}`,
|
||||||
|
`Deductible Expenses,${taxData.deductibleExpenses.toFixed(2)}`,
|
||||||
|
`Net Profit,${taxData.netProfit.toFixed(2)}`,
|
||||||
|
`Est. Self-Employment Tax (15.3%),${taxData.selfEmploymentTax.toFixed(2)}`,
|
||||||
|
`Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`,
|
||||||
|
`Total Estimated Tax,${taxData.totalEstimated.toFixed(2)}`,
|
||||||
|
];
|
||||||
|
const blob = new Blob([rows.join("\n")], {
|
||||||
|
type: "text/csv;charset=utf-8;",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `tax-report-${taxYear}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="page-enter space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Reports"
|
||||||
|
description="Revenue and tax analytics"
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-enter space-y-6 pb-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Reports"
|
||||||
|
description="Revenue and tax analytics"
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="overview">
|
||||||
|
<TrendingUp className="mr-1.5 h-4 w-4" /> Overview
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tax">
|
||||||
|
<FileText className="mr-1.5 h-4 w-4" /> Tax Summary
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ── OVERVIEW TAB ── */}
|
||||||
|
<TabsContent value="overview" className="mt-4 space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-primary/10 rounded p-1.5">
|
||||||
|
<DollarSign className="text-primary h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Total Revenue
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">
|
||||||
|
{formatCurrency(overviewData?.totalRevenue ?? 0)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="rounded bg-yellow-500/10 p-1.5">
|
||||||
|
<Clock className="h-4 w-4 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Pending
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">
|
||||||
|
{formatCurrency(overviewData?.totalPending ?? 0)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="rounded bg-blue-500/10 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="rounded bg-green-500/10 p-1.5">
|
||||||
|
<Users className="h-4 w-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Total Hours
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">
|
||||||
|
{(overviewData?.totalHours ?? 0).toFixed(1)}h
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-48 w-full md:h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={overviewData?.revenueByMonth ?? []}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="revenueGrad"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor="hsl(142, 76%, 36%)"
|
||||||
|
stopOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor="hsl(142, 76%, 36%)"
|
||||||
|
stopOpacity={0.02}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
className="stroke-border"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v: number) =>
|
||||||
|
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [
|
||||||
|
formatCurrency(toNumericChartValue(value)),
|
||||||
|
"Revenue",
|
||||||
|
]}
|
||||||
|
contentStyle={{
|
||||||
|
background: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="hsl(142, 76%, 36%)"
|
||||||
|
fill="url(#revenueGrad)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" /> Top Clients by Revenue
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!overviewData?.topClients.length ? (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
|
No paid invoices yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="h-48 md:h-56">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={overviewData.topClients}
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v: number) =>
|
||||||
|
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={80}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [
|
||||||
|
formatCurrency(toNumericChartValue(value)),
|
||||||
|
"Revenue",
|
||||||
|
]}
|
||||||
|
contentStyle={{
|
||||||
|
background: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="revenue"
|
||||||
|
fill="hsl(142, 76%, 36%)"
|
||||||
|
radius={[0, 4, 4, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Invoice Status Breakdown</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Object.entries(overviewData?.statusCount ?? {}).map(
|
||||||
|
([status, count]) => (
|
||||||
|
<div
|
||||||
|
key={status}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<StatusBadge status={status as never} />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${invoices.length ? (count / invoices.length) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground w-8 text-right text-sm">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{invoices.length === 0 && (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
|
No invoices yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Activity</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="divide-y">
|
||||||
|
{stats.recentInvoices.map((inv) => (
|
||||||
|
<div
|
||||||
|
key={inv.id}
|
||||||
|
className="flex items-center justify-between py-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{inv.client?.name ?? "—"}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{new Date(inv.issueDate).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusBadge
|
||||||
|
status={
|
||||||
|
getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
) as never
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{formatCurrency(inv.totalAmount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── TAX SUMMARY TAB ── */}
|
||||||
|
<TabsContent value="tax" className="mt-4 space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium">Tax Year</span>
|
||||||
|
<Select value={taxYear} onValueChange={setTaxYear}>
|
||||||
|
<SelectTrigger className="w-28">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableYears.map((y) => (
|
||||||
|
<SelectItem key={y} value={String(y)}>
|
||||||
|
{y}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={exportCSV} className="gap-2">
|
||||||
|
<Download className="h-4 w-4" /> Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Income */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-5 w-5" /> Income
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Gross Income (paid invoices)
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.grossIncome)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{taxData.taxCollected > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Tax Collected from Clients
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.taxCollected)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between font-medium">
|
||||||
|
<span>Total Invoiced (inc. tax)</span>
|
||||||
|
<span>{formatCurrency(taxData.totalInvoiced)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Expenses */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Receipt className="h-5 w-5" /> Expenses & Deductions
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Total Expenses</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.totalExpenses)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Tax-Deductible Expenses
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-green-600">
|
||||||
|
{formatCurrency(taxData.deductibleExpenses)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{taxData.totalExpenses > 0 &&
|
||||||
|
taxData.deductibleExpenses === 0 && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Mark expenses as "Tax Deductible" in the Expenses
|
||||||
|
page to include them here.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Estimated tax */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" /> Estimated Tax Liability
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Net Profit (income − deductible expenses)
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.netProfit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Self-Employment Tax (15.3% on 92.35% of net)
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.selfEmploymentTax)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Federal Income Tax (est. 22% bracket)
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.federalEstimate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between text-lg font-bold">
|
||||||
|
<span>Total Estimated Tax</span>
|
||||||
|
<span className="text-destructive">
|
||||||
|
{formatCurrency(taxData.totalEstimated)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground pt-1 text-xs">
|
||||||
|
Assumes US self-employment tax rules and the 22% federal
|
||||||
|
bracket. Consult a tax professional for accurate filing.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quarterly chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quarterly Breakdown</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-48 md:h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={taxData.quarters}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
className="stroke-border"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v: number) =>
|
||||||
|
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value, name) => [
|
||||||
|
formatCurrency(toNumericChartValue(value)),
|
||||||
|
name === "income" ? "Income" : "Expenses",
|
||||||
|
]}
|
||||||
|
contentStyle={{
|
||||||
|
background: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="income"
|
||||||
|
name="income"
|
||||||
|
fill="hsl(142, 76%, 36%)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="expenses"
|
||||||
|
name="expenses"
|
||||||
|
fill="hsl(0, 84%, 60%)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
opacity={0.75}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mt-2 flex justify-center gap-6 text-xs">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" />{" "}
|
||||||
|
Income
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" />{" "}
|
||||||
|
Expenses
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user