mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -43,6 +43,13 @@ beenvoice is a professional invoicing application built with the T3 stack (Next.
|
||||
- Protected routes require authentication
|
||||
- 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
|
||||
|
||||
### UI Components (shadcn/ui)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
README.md
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
drizzle/*.sql
|
||||
drizzle/*-journal
|
||||
.vscode
|
||||
.idea
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
dist
|
||||
build
|
||||
|
||||
+39
-19
@@ -1,23 +1,43 @@
|
||||
# Since the ".env" file is gitignored, you can use the ".env.example" file to
|
||||
# build a new ".env" file when you clone the repo. Keep this file up-to-date
|
||||
# when you add new variables to `.env`.
|
||||
# Base application env
|
||||
NODE_ENV="development"
|
||||
PORT="3000"
|
||||
HOSTNAME="0.0.0.0"
|
||||
|
||||
# This file will be committed to version control, so make sure not to have any
|
||||
# secrets in it. If you are cloning this repo, create a copy of this file named
|
||||
# ".env" and populate it with your secrets.
|
||||
|
||||
# When adding additional environment variables, the schema in "/src/env.js"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Next Auth
|
||||
# Auth
|
||||
# You can generate a new secret on the command line with:
|
||||
# npx auth secret
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
AUTH_SECRET=""
|
||||
# openssl rand -base64 32
|
||||
AUTH_SECRET="your-auth-secret"
|
||||
BETTER_AUTH_URL="http://localhost:3000" # Set to your production URL in production
|
||||
|
||||
# Next Auth Discord Provider
|
||||
AUTH_DISCORD_ID=""
|
||||
AUTH_DISCORD_SECRET=""
|
||||
# App URL
|
||||
# Used for client-side redirects and base URLs
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
|
||||
# Drizzle
|
||||
DATABASE_URL="file:./db.sqlite"
|
||||
# Database (Postgres)
|
||||
# These are required for Docker container initialization
|
||||
POSTGRES_USER="postgres"
|
||||
POSTGRES_PASSWORD="postgres"
|
||||
POSTGRES_DB="postgres"
|
||||
|
||||
# Connect string for the app
|
||||
DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres"
|
||||
# Disable SSL for Docker local Postgres; set to false or remove for managed Postgres
|
||||
DB_DISABLE_SSL="true"
|
||||
|
||||
# Email (Resend). Replace with real keys in production
|
||||
RESEND_API_KEY="your-resend-api-key"
|
||||
RESEND_DOMAIN=""
|
||||
|
||||
# Analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID="your-website-id-here"
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL="https://analytics.umami.is/script.js"
|
||||
# Build tweaks
|
||||
# SKIP_ENV_VALIDATION=1
|
||||
|
||||
# SSO / Authentik (Optional - only needed if using SSO authentication)
|
||||
# Configure these if you want to enable Single Sign-On with Authentik OIDC
|
||||
# The issuer should be your Authentik application's OAuth2 provider URL
|
||||
# Example: https://auth.example.com/application/o/your-app-slug
|
||||
AUTHENTIK_ISSUER=""
|
||||
AUTHENTIK_CLIENT_ID=""
|
||||
AUTHENTIK_CLIENT_SECRET=""
|
||||
+1
-3
@@ -35,9 +35,7 @@ yarn-error.log*
|
||||
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
.env*.production
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
FROM oven/bun:1.2.19 as deps
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies (only package manifests copied first for better caching)
|
||||
COPY package.json bun.lock ./
|
||||
# Install minimal toolchain for native devDependencies (e.g., better-sqlite3) during build
|
||||
# Minimal toolchain (kept for safety, but we skip dev deps)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& ln -sf /usr/bin/python3 /usr/bin/python \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Install all deps (including dev) for build tooling like @tailwindcss/postcss
|
||||
RUN bun install --frozen-lockfile --verbose
|
||||
|
||||
FROM oven/bun:1.2.19 as builder
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV SKIP_ENV_VALIDATION=1
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Build Next.js app (no memory constraints)
|
||||
RUN bun run build
|
||||
|
||||
FROM oven/bun:1.2.19 as runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
# Create non-root user and group
|
||||
RUN addgroup --system --gid 1001 beenvoice \
|
||||
&& adduser --system --uid 1001 --ingroup beenvoice beenvoice
|
||||
|
||||
# Copy runtime artifacts and install production deps
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/bun.lock ./bun.lock
|
||||
RUN bun install --frozen-lockfile --production --verbose
|
||||
COPY --from=builder /app/start.sh ./start.sh
|
||||
COPY --from=builder /app/next.config.js ./next.config.js
|
||||
COPY --from=builder /app/src ./src
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
|
||||
COPY --from=builder /app/.env.example ./.env.example
|
||||
|
||||
RUN chmod +x ./start.sh
|
||||
|
||||
USER 1001
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["./start.sh"]
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||

|
||||
|
||||
# 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.
|
||||
@@ -6,30 +8,37 @@ A modern, professional invoicing application built for freelancers and small bus
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🔐 Secure Authentication** - Email/password registration and sign-in with NextAuth.js
|
||||
- **🔐 Secure Authentication** - Email/password registration and sign-in with better-auth, plus SSO via Authentik OIDC
|
||||
- **👥 Client Management** - Create, edit, and manage client information
|
||||
- **🏢 Business Profiles** - Manage your business details, logo, and email settings
|
||||
- **📄 Professional Invoices** - Generate detailed invoices with line items
|
||||
- **📅 Timesheet View** - Calendar-based time entry with month and week views
|
||||
- **📧 Email Delivery** - Send invoices via email using Resend
|
||||
- **📥 PDF Export** - Download invoices as professional PDFs
|
||||
- **📊 CSV Import** - Bulk import invoice data from CSV files
|
||||
- **💰 Flexible Pricing** - Set custom rates and calculate totals automatically
|
||||
- **📱 Responsive Design** - Works seamlessly on desktop, tablet, and mobile
|
||||
- **🎨 Modern UI** - Clean, professional interface built with shadcn/ui
|
||||
- **⚡ Type-Safe** - Full TypeScript support with tRPC for API calls
|
||||
- **💾 Local Database** - SQLite database with Drizzle ORM
|
||||
- **💾 PostgreSQL Database** - Robust relational database with Drizzle ORM
|
||||
|
||||
## 🚀 Tech Stack
|
||||
|
||||
- **Frontend**: Next.js 15 with App Router
|
||||
- **Frontend**: Next.js 16 with App Router
|
||||
- **Backend**: tRPC for type-safe API calls
|
||||
- **Database**: Drizzle ORM with LibSQL (SQLite)
|
||||
- **Authentication**: NextAuth.js with email/password
|
||||
- **UI Components**: shadcn/ui with Tailwind CSS
|
||||
- **Styling**: Geist font family
|
||||
- **Package Manager**: Bun (with npm fallback)
|
||||
- **Database**: Drizzle ORM with PostgreSQL
|
||||
- **Authentication**: better-auth with email/password and Authentik OIDC SSO
|
||||
- **UI Components**: shadcn/ui with Tailwind CSS v4
|
||||
- **Email**: Resend for transactional email delivery
|
||||
- **PDF**: @react-pdf/renderer for invoice PDF generation
|
||||
- **Package Manager**: Bun
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ or Bun
|
||||
- Docker & Docker Compose (for local PostgreSQL)
|
||||
- Git
|
||||
|
||||
### Quick Start
|
||||
@@ -42,11 +51,7 @@ A modern, professional invoicing application built for freelancers and small bus
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
# Using Bun (recommended)
|
||||
bun install
|
||||
|
||||
# Or using npm
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Set up environment variables**
|
||||
@@ -56,22 +61,39 @@ A modern, professional invoicing application built for freelancers and small bus
|
||||
|
||||
Edit `.env.local` and add your configuration:
|
||||
```env
|
||||
DATABASE_URL="file:./db.sqlite"
|
||||
NEXTAUTH_SECRET="your-secret-key-here"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
# Database
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice"
|
||||
DB_DISABLE_SSL="true"
|
||||
|
||||
# Authentication
|
||||
AUTH_SECRET="your-secret-key-here"
|
||||
BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
# Application
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
NODE_ENV="development"
|
||||
|
||||
# Email (optional for local dev)
|
||||
RESEND_API_KEY="your-resend-api-key"
|
||||
RESEND_DOMAIN="yourdomain.com"
|
||||
```
|
||||
|
||||
4. **Initialize the database**
|
||||
4. **Start the database**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
5. **Push the database schema**
|
||||
```bash
|
||||
bun run db:push
|
||||
```
|
||||
|
||||
5. **Start the development server**
|
||||
6. **Start the development server**
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
6. **Open your browser**
|
||||
7. **Open your browser**
|
||||
Navigate to [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
## 🏗️ Project Structure
|
||||
@@ -80,21 +102,28 @@ A modern, professional invoicing application built for freelancers and small bus
|
||||
beenvoice/
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router pages
|
||||
│ │ ├── api/ # API routes (NextAuth, tRPC)
|
||||
│ │ ├── api/ # API routes (better-auth, tRPC)
|
||||
│ │ ├── auth/ # Authentication pages
|
||||
│ │ ├── clients/ # Client management pages
|
||||
│ │ ├── invoices/ # Invoice management pages
|
||||
│ │ ├── dashboard/ # Main app pages
|
||||
│ │ │ ├── clients/ # Client management pages
|
||||
│ │ │ ├── invoices/ # Invoice management pages
|
||||
│ │ │ └── businesses/ # Business profile pages
|
||||
│ │ └── _components/ # Page-specific components
|
||||
│ ├── components/ # Shared UI components
|
||||
│ │ ├── ui/ # shadcn/ui components
|
||||
│ │ ├── data/ # Data display components
|
||||
│ │ ├── forms/ # Form components
|
||||
│ │ └── layout/ # Layout components
|
||||
│ ├── server/ # Server-side code
|
||||
│ │ ├── api/ # tRPC routers
|
||||
│ │ ├── auth/ # NextAuth configuration
|
||||
│ │ └── db/ # Database schema and connection
|
||||
│ ├── lib/ # Utilities (auth, pdf export, etc.)
|
||||
│ ├── styles/ # Global styles
|
||||
│ └── trpc/ # tRPC client configuration
|
||||
├── drizzle/ # Database migrations
|
||||
├── public/ # Static assets
|
||||
└── docs/ # Documentation
|
||||
├── docs/ # Documentation
|
||||
└── docker-compose.yml # Local PostgreSQL setup
|
||||
```
|
||||
|
||||
## 🎯 Usage
|
||||
@@ -104,41 +133,53 @@ beenvoice/
|
||||
1. **Register an Account**
|
||||
- Visit the sign-up page
|
||||
- Enter your name, email, and password
|
||||
- Verify your email (if configured)
|
||||
|
||||
2. **Add Your First Client**
|
||||
2. **Set Up Your Business**
|
||||
- Navigate to Business Settings
|
||||
- Add your business name, contact info, and logo
|
||||
- Configure email settings for invoice delivery (Resend API key + domain)
|
||||
|
||||
3. **Add Your First Client**
|
||||
- Navigate to the Clients page
|
||||
- Click "Add New Client"
|
||||
- Fill in client details (name, email, phone, address)
|
||||
|
||||
3. **Create an Invoice**
|
||||
4. **Create an Invoice**
|
||||
- Go to the Invoices page
|
||||
- Click "Create New Invoice"
|
||||
- Select a client
|
||||
- Select a client and optionally a business profile
|
||||
- Add line items with descriptions, dates, hours, and rates
|
||||
- Save and generate your invoice
|
||||
- Use the Timesheet tab for calendar-based time entry
|
||||
- Save and send or download as PDF
|
||||
|
||||
### Features Overview
|
||||
|
||||
#### Client Management
|
||||
- Create and edit client profiles
|
||||
- Store contact information and addresses
|
||||
- Set default hourly rates per client
|
||||
- Search and filter client list
|
||||
- View client history
|
||||
|
||||
#### Invoice Creation
|
||||
- Select from existing clients
|
||||
- Add multiple line items
|
||||
- Select from existing clients and business profiles
|
||||
- Add multiple line items with drag-and-drop reordering
|
||||
- Set custom rates per item
|
||||
- Automatic total calculations
|
||||
- Automatic total calculations with configurable tax rate
|
||||
- Timesheet calendar view for date-based time tracking
|
||||
- Professional invoice formatting
|
||||
|
||||
#### Invoice Delivery
|
||||
- Send invoices via email directly from the app
|
||||
- Rich text email composer with preview
|
||||
- Resend and re-deliver sent invoices
|
||||
- Track invoice status: Draft → Sent → Paid (+ Overdue)
|
||||
|
||||
#### User Interface
|
||||
- Clean, modern design
|
||||
- Responsive layout
|
||||
- Intuitive navigation
|
||||
- Fully responsive — desktop, tablet, and mobile
|
||||
- Intuitive navigation with breadcrumbs
|
||||
- Toast notifications for feedback
|
||||
- Modal dialogs for forms
|
||||
- Dark mode support
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
@@ -146,44 +187,53 @@ beenvoice/
|
||||
|
||||
```bash
|
||||
# Development
|
||||
bun run dev # Start development server
|
||||
bun run dev # Start development server (Turbo)
|
||||
bun run build # Build for production
|
||||
bun run start # Start production server
|
||||
|
||||
# Database
|
||||
bun run db:push # Push schema changes to database
|
||||
bun run db:migrate # Run migrations
|
||||
bun run db:studio # Open Drizzle Studio
|
||||
bun run db:generate # Generate new migration
|
||||
|
||||
# Docker
|
||||
bun run docker:up # Start local PostgreSQL via Docker
|
||||
bun run docker:down # Stop Docker services
|
||||
|
||||
# Code Quality
|
||||
bun run lint # Run ESLint
|
||||
bun run format # Format code with Prettier
|
||||
bun run type-check # Run TypeScript type checking
|
||||
bun run lint:fix # Fix ESLint issues
|
||||
bun run format:write # Format code with Prettier
|
||||
bun run typecheck # Run TypeScript type checking
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
|
||||
The application uses four main tables:
|
||||
The application uses the following core tables:
|
||||
|
||||
- **users**: User accounts and authentication
|
||||
- **clients**: Client information and contact details
|
||||
- **invoices**: Invoice headers with client relationships
|
||||
- **invoice_items**: Individual line items with pricing
|
||||
- **users** - User accounts and authentication
|
||||
- **sessions** - Active user sessions
|
||||
- **clients** - Client information and contact details
|
||||
- **businesses** - Business profiles with email/logo settings
|
||||
- **invoices** - Invoice headers with client and business relationships
|
||||
- **invoice_items** - Individual line items with pricing and position ordering
|
||||
|
||||
### API Development
|
||||
|
||||
All API endpoints are built with tRPC for type safety:
|
||||
|
||||
- **Authentication**: NextAuth.js integration
|
||||
- **Authentication**: better-auth integration (email/password + OIDC)
|
||||
- **Clients**: CRUD operations for client management
|
||||
- **Invoices**: Invoice creation and management
|
||||
- **Businesses**: Business profile management
|
||||
- **Invoices**: Invoice creation, management, and status tracking
|
||||
- **Validation**: Zod schemas for input validation
|
||||
|
||||
## 🎨 Customization
|
||||
|
||||
### Styling
|
||||
|
||||
The app uses Tailwind CSS with a custom design system:
|
||||
The app uses Tailwind CSS v4 with a custom design system:
|
||||
|
||||
- **Primary Color**: Green (#16a34a)
|
||||
- **Font**: Geist for professional typography
|
||||
@@ -199,31 +249,54 @@ Update the logo and colors in:
|
||||
|
||||
## 🚀 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
|
||||
2. Connect your repository to Vercel
|
||||
3. Set environment variables in Vercel dashboard
|
||||
4. Deploy automatically on push
|
||||
1. **Build the application:**
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
### Other Platforms
|
||||
2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production)
|
||||
|
||||
The app can be deployed to any platform that supports Next.js:
|
||||
3. **Run database migrations:**
|
||||
```bash
|
||||
bun run db:push
|
||||
```
|
||||
|
||||
- **Netlify**: Use the Next.js build command
|
||||
- **Railway**: Connect your GitHub repository
|
||||
- **DigitalOcean App Platform**: Deploy with automatic scaling
|
||||
4. **Start the server:**
|
||||
```bash
|
||||
bun start
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Required for production:
|
||||
|
||||
```env
|
||||
DATABASE_URL="your-database-url"
|
||||
NEXTAUTH_SECRET="your-secret-key"
|
||||
NEXTAUTH_URL="https://your-domain.com"
|
||||
DATABASE_URL="postgresql://user:password@host:5432/dbname"
|
||||
AUTH_SECRET="your-long-random-secret"
|
||||
BETTER_AUTH_URL="https://your-domain.com"
|
||||
NEXT_PUBLIC_APP_URL="https://your-domain.com"
|
||||
NODE_ENV="production"
|
||||
|
||||
# Email (required for invoice sending)
|
||||
RESEND_API_KEY="re_xxxxxxxxxxxx"
|
||||
RESEND_DOMAIN="yourdomain.com"
|
||||
|
||||
# Optional: Authentik SSO
|
||||
AUTHENTIK_ISSUER="https://your-authentik-instance/application/o/beenvoice/"
|
||||
AUTHENTIK_CLIENT_ID="your-client-id"
|
||||
AUTHENTIK_CLIENT_SECRET="your-client-secret"
|
||||
```
|
||||
|
||||
### Other Platforms
|
||||
|
||||
The app can be deployed to any platform that supports Next.js:
|
||||
|
||||
- **Coolify**: Deploy with Docker Compose support
|
||||
- **Railway**: Connect your GitHub repository (includes managed PostgreSQL)
|
||||
- **DigitalOcean App Platform**: Deploy with automatic scaling
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
@@ -237,8 +310,7 @@ NEXTAUTH_URL="https://your-domain.com"
|
||||
- Follow TypeScript best practices
|
||||
- Use shadcn/ui components for consistency
|
||||
- Implement proper error handling
|
||||
- Add tests for new features
|
||||
- Follow the existing code style
|
||||
- Follow the existing code style (Prettier + ESLint configs provided)
|
||||
|
||||
## 📄 License
|
||||
|
||||
@@ -248,14 +320,14 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
||||
|
||||
- [T3 Stack](https://create.t3.gg/) for the excellent development stack
|
||||
- [shadcn/ui](https://ui.shadcn.com/) for beautiful UI components
|
||||
- [NextAuth.js](https://next-auth.js.org/) for authentication
|
||||
- [better-auth](https://www.better-auth.com/) for modern authentication
|
||||
- [Drizzle ORM](https://orm.drizzle.team/) for database management
|
||||
- [Resend](https://resend.com/) for reliable email delivery
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/yourusername/beenvoice/issues)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/beenvoice/discussions)
|
||||
- **Email**: support@beenvoice.com
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
container_name: beenvoice-db
|
||||
env_file:
|
||||
- .env.local
|
||||
volumes:
|
||||
- beenvoice_pg_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
beenvoice_pg_data:
|
||||
driver: local
|
||||
|
||||
|
||||
@@ -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 {
|
||||
schema: "./src/server/db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
dbCredentials: env.DATABASE_AUTH_TOKEN
|
||||
? {
|
||||
url: env.DATABASE_URL,
|
||||
token: env.DATABASE_AUTH_TOKEN,
|
||||
}
|
||||
: {
|
||||
url: env.DATABASE_URL,
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
tablesFilter: ["beenvoice_*"],
|
||||
} 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;
|
||||
@@ -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`);
|
||||
+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,19 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1752275489999,
|
||||
"tag": "0000_unique_loa",
|
||||
"version": "7",
|
||||
"when": 1775354242672,
|
||||
"tag": "0000_glossy_magneto",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1775356013998,
|
||||
"tag": "0001_supreme_the_enforcers",
|
||||
"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";
|
||||
// @ts-ignore -- no types for this plugin
|
||||
import drizzle from "eslint-plugin-drizzle";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
});
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [".next"],
|
||||
},
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
...nextCoreWebVitals,
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
plugins: {
|
||||
|
||||
+1
-3
@@ -6,9 +6,7 @@ import "./src/env.js";
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
serverExternalPackages: ['pg'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
Generated
-8182
File diff suppressed because it is too large
Load Diff
+71
-64
@@ -5,105 +5,112 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"check": "next lint && tsc --noEmit",
|
||||
"check": "eslint . && tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:migrate": "bun src/server/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:push-local-to-live": "node scripts/migrate-to-turso.js",
|
||||
"db:push-simple": "node scripts/migrate-simple.js",
|
||||
"db:push-direct": "node scripts/migrate-direct.js",
|
||||
"db:export-data": "node scripts/export-data.js",
|
||||
"db:import-data": "node scripts/import-data-final.js",
|
||||
"db:clone": "./scripts/clone-local.sh",
|
||||
"docker:up": "colima start && docker-compose up -d",
|
||||
"docker:down": "docker-compose down && colima stop",
|
||||
"deploy": "drizzle-kit push && next build",
|
||||
"dev": "next dev --turbo",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/drizzle-adapter": "^1.7.2",
|
||||
"@better-auth/sso": "^1.4.12",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@libsql/client": "^0.14.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-pdf/renderer": "^4.3.1",
|
||||
"@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",
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/react-query": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chrono-node": "^2.8.3",
|
||||
"@tiptap/extension-color": "^3.13.0",
|
||||
"@tiptap/extension-list-item": "^3.13.0",
|
||||
"@tiptap/extension-text-align": "^3.13.0",
|
||||
"@tiptap/extension-text-style": "^3.13.0",
|
||||
"@tiptap/react": "^3.13.0",
|
||||
"@tiptap/starter-kit": "^3.13.0",
|
||||
"@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",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"file-saver": "^2.0.5",
|
||||
"lucide": "^0.525.0",
|
||||
"framer-motion": "^12.23.26",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "^15.4.1",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.8.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "^16.2.2",
|
||||
"pg": "8.13.1",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.12.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"recharts": "^3.5.1",
|
||||
"resend": "^4.8.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sonner": "^2.0.6",
|
||||
"superjson": "^2.2.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"turso": "^0.1.0",
|
||||
"zod": "^3.24.2"
|
||||
"sonner": "^2.0.7",
|
||||
"superjson": "^2.2.6",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20.14.10",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^20.19.26",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/raf": "^3.4.3",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"drizzle-kit": "^0.30.5",
|
||||
"eslint": "^9.23.0",
|
||||
"eslint-config-next": "^15.2.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"baseline-browser-mapping": "^2.9.6",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.10",
|
||||
"eslint-plugin-drizzle": "^0.2.3",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.27.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.49.0"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@tailwindcss/oxide",
|
||||
"better-sqlite3",
|
||||
"core-js",
|
||||
"esbuild",
|
||||
"sharp",
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
export default {
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||
export default {
|
||||
const config = {
|
||||
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,101 @@
|
||||
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));
|
||||
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { eq, and, gt } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
|
||||
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);
|
||||
|
||||
// Update user with new password and clear reset token
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
+152
-88
@@ -1,20 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { useRouter } 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 { 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() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -24,54 +23,112 @@ function RegisterForm() {
|
||||
async function handleRegister(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
firstName,
|
||||
lastName,
|
||||
name: `${firstName} ${lastName}`,
|
||||
email,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (res.ok) {
|
||||
toast.success("Account created successfully! Please sign in.");
|
||||
const signInUrl =
|
||||
callbackUrl !== "/dashboard"
|
||||
? `/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
: "/auth/signin";
|
||||
router.push(signInUrl);
|
||||
router.push("/auth/signin");
|
||||
} else {
|
||||
const error = await res.text();
|
||||
toast.error(error || "Failed to create account");
|
||||
const data = (await res.json()) as { error?: string };
|
||||
toast.error(data.error ?? "Registration failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-form-container">
|
||||
{/* Logo and Welcome */}
|
||||
<div className="auth-header">
|
||||
<Logo size="lg" className="mx-auto" />
|
||||
<div>
|
||||
<h1 className="auth-title">Join beenvoice</h1>
|
||||
<p className="auth-subtitle">Create your account to get started</p>
|
||||
<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-6xl 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">
|
||||
<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>
|
||||
|
||||
{/* Registration Form */}
|
||||
<Card className="auth-card">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="auth-card-title">Create Account</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleRegister} className="auth-form">
|
||||
<div className="auth-input-grid">
|
||||
<div className="auth-input-group">
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Rocket className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold">Quick Setup</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
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>
|
||||
<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
|
||||
id="firstName"
|
||||
type="text"
|
||||
@@ -79,118 +136,125 @@ function RegisterForm() {
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="form-input-with-icon"
|
||||
placeholder="First name"
|
||||
className="h-11 pl-10"
|
||||
placeholder="John"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="auth-input-group">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Last Name</Label>
|
||||
<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
|
||||
id="lastName"
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
required
|
||||
className="form-input-with-icon"
|
||||
placeholder="Last name"
|
||||
className="h-11 pl-10"
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</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">
|
||||
<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
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="form-input-with-icon"
|
||||
placeholder="Enter your email"
|
||||
className="h-11 pl-10"
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="auth-input-group">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<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
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="form-input-with-icon"
|
||||
placeholder="Create a password"
|
||||
className="h-11 pl-10"
|
||||
placeholder="Create a strong password"
|
||||
/>
|
||||
</div>
|
||||
<p className="auth-password-help">
|
||||
Must be at least 6 characters
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Must be at least 8 characters long
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="auth-submit-btn"
|
||||
className="h-11 w-full"
|
||||
disabled={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>
|
||||
) : (
|
||||
<>
|
||||
Create Account
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Create Account</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="auth-footer-text">
|
||||
<span className="text-muted-foreground">
|
||||
|
||||
<div className="text-center text-sm">
|
||||
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>
|
||||
<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>
|
||||
</CardContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<Suspense
|
||||
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>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<RegisterForm />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+190
-69
@@ -1,16 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
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 { 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 { Mail, Lock, ArrowRight } from "lucide-react";
|
||||
import { LegalModal } from "~/components/ui/legal-modal";
|
||||
import {
|
||||
Mail,
|
||||
Lock,
|
||||
ArrowRight,
|
||||
Users,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
|
||||
function SignInForm() {
|
||||
const router = useRouter();
|
||||
@@ -24,16 +32,15 @@ function SignInForm() {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
const { error } = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (result?.error) {
|
||||
toast.error("Invalid email or password");
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Invalid email or password");
|
||||
} else {
|
||||
toast.success("Signed in successfully!");
|
||||
router.push(callbackUrl);
|
||||
@@ -41,31 +48,131 @@ function SignInForm() {
|
||||
}
|
||||
}
|
||||
|
||||
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="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
|
||||
<div className="flex min-h-screen items-center justify-center relative overflow-hidden">
|
||||
{/* Blob Background */}
|
||||
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
|
||||
<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="w-[800px] h-[800px] bg-neutral-400/30 dark:bg-neutral-500/20 rounded-full blur-3xl animate-blob"></div>
|
||||
</div>
|
||||
|
||||
<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-2xl md:bg-background/80 md:backdrop-blur-xl md:border-border/50 md:rounded-3xl">
|
||||
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||
{/* Hero Section - Hidden on mobile */}
|
||||
<div className="bg-primary/5 relative hidden md:flex md:flex-col md:justify-center md:p-12 border-r border-border/50">
|
||||
<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 font-heading">
|
||||
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="font-semibold text-foreground">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="font-semibold text-foreground">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="font-semibold text-foreground">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 */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-center text-xl">Sign In</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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-3xl font-bold font-heading">Sign In</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Enter your credentials to access your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="w-full h-11 relative 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="w-full border-t border-border/50" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSignIn} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<div className="relative">
|
||||
<Mail className="form-icon-left" />
|
||||
<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"
|
||||
@@ -73,83 +180,97 @@ function SignInForm() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="form-input-with-icon"
|
||||
placeholder="Enter your email"
|
||||
className="h-11 pl-10 bg-background/50 border-border/60 focus:bg-background 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="form-icon-left" />
|
||||
<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="form-input-with-icon"
|
||||
className="h-11 pl-10 bg-background/50 border-border/60 focus:bg-background transition-all"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl text-base shadow-lg shadow-primary/20 hover:shadow-primary/30"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
"Signing in..."
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
Sign In
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Sign In</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
|
||||
<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>
|
||||
<Link href="/auth/register" className="nav-link-brand">
|
||||
Create one now
|
||||
</Link>
|
||||
}
|
||||
/>{" "}
|
||||
and{" "}
|
||||
<LegalModal
|
||||
type="privacy"
|
||||
trigger={
|
||||
<span className="text-primary inline cursor-pointer hover:underline">
|
||||
Privacy Policy
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</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() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<SignInForm />
|
||||
</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 (
|
||||
<div className="space-y-6 pb-32">
|
||||
<PageHeader
|
||||
title={business.name}
|
||||
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
|
||||
description="View business details and information"
|
||||
variant="gradient"
|
||||
>
|
||||
@@ -55,7 +55,7 @@ export default async function BusinessDetailPage({
|
||||
<span>Back to Businesses</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="btn-brand-primary shadow-md">
|
||||
<Button asChild variant="default" className="shadow-md">
|
||||
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<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">
|
||||
{/* Business Information Card */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="card-primary">
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="bg-blue-subtle rounded-lg p-2">
|
||||
<Building className="text-icon-blue h-5 w-5" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Building className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<span>Business Information</span>
|
||||
</CardTitle>
|
||||
@@ -84,8 +84,8 @@ export default async function BusinessDetailPage({
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{business.email && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-green-subtle rounded-lg p-2">
|
||||
<Mail className="text-icon-green h-4 w-4" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Mail className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
@@ -100,8 +100,8 @@ export default async function BusinessDetailPage({
|
||||
|
||||
{business.phone && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-green-subtle rounded-lg p-2">
|
||||
<Phone className="text-icon-green h-4 w-4" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Phone className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
@@ -116,8 +116,8 @@ export default async function BusinessDetailPage({
|
||||
|
||||
{business.website && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-green-subtle rounded-lg p-2">
|
||||
<Globe className="text-icon-green h-4 w-4" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Globe className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
@@ -137,8 +137,8 @@ export default async function BusinessDetailPage({
|
||||
|
||||
{business.taxId && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-green-subtle rounded-lg p-2">
|
||||
<Hash className="text-icon-green h-4 w-4" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Hash className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
@@ -162,8 +162,8 @@ export default async function BusinessDetailPage({
|
||||
Business Address
|
||||
</h3>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="bg-green-subtle rounded-lg p-2">
|
||||
<MapPin className="text-icon-green h-4 w-4" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<MapPin className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
{business.addressLine1 && (
|
||||
@@ -205,8 +205,8 @@ export default async function BusinessDetailPage({
|
||||
<h3 className="mb-4 text-lg font-semibold">Business Details</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-green-subtle rounded-lg p-2">
|
||||
<Calendar className="text-icon-green h-4 w-4" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Calendar className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
@@ -218,11 +218,32 @@ export default async function BusinessDetailPage({
|
||||
</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 */}
|
||||
{business.isDefault && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-green-subtle rounded-lg p-2">
|
||||
<Building className="text-icon-green h-4 w-4" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Building className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
@@ -230,7 +251,7 @@ export default async function BusinessDetailPage({
|
||||
</p>
|
||||
<Badge
|
||||
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
|
||||
</Badge>
|
||||
@@ -245,11 +266,11 @@ export default async function BusinessDetailPage({
|
||||
|
||||
{/* Settings & Actions Card */}
|
||||
<div className="space-y-6">
|
||||
<Card className="card-primary">
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="bg-blue-subtle rounded-lg p-2">
|
||||
<Building className="text-icon-blue h-5 w-5" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Building className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<span>Quick Actions</span>
|
||||
</CardTitle>
|
||||
@@ -281,7 +302,7 @@ export default async function BusinessDetailPage({
|
||||
</Card>
|
||||
|
||||
{/* Information Card */}
|
||||
<Card className="card-primary">
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">About This Business</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -292,7 +313,7 @@ export default async function BusinessDetailPage({
|
||||
represents your company information to clients.
|
||||
</p>
|
||||
{business.isDefault && (
|
||||
<p className="text-green-600 dark:text-green-400">
|
||||
<p className="text-primary">
|
||||
This is your default business and will be automatically
|
||||
selected when creating new invoices.
|
||||
</p>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { toast } from "sonner";
|
||||
interface Business {
|
||||
id: string;
|
||||
name: string;
|
||||
nickname: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
addressLine1: string | null;
|
||||
@@ -42,17 +43,6 @@ interface BusinessesDataTableProps {
|
||||
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) {
|
||||
const router = useRouter();
|
||||
const [businessToDelete, setBusinessToDelete] = useState<Business | null>(
|
||||
@@ -61,6 +51,11 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const searchableBusinesses = businesses.map((b) => ({
|
||||
...b,
|
||||
searchValue: `${b.name} ${b.nickname ?? ""}`.trim(),
|
||||
}));
|
||||
|
||||
const deleteBusinessMutation = api.businesses.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Business deleted successfully");
|
||||
@@ -91,19 +86,30 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||
const business = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-blue-subtle hidden rounded-lg p-2 sm:flex">
|
||||
<Building className="text-icon-blue h-4 w-4" />
|
||||
<div className="bg-primary/10 hidden p-2 sm:flex">
|
||||
<Building className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{business.name}</p>
|
||||
<p className="text-muted-foreground truncate text-sm">
|
||||
{business.email ?? "—"}
|
||||
{business.nickname ?? "—"}
|
||||
</p>
|
||||
</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",
|
||||
header: ({ column }) => (
|
||||
@@ -115,26 +121,6 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||
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",
|
||||
header: ({ column }) => (
|
||||
@@ -175,6 +161,15 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "searchValue",
|
||||
header: "Search",
|
||||
cell: () => null,
|
||||
meta: {
|
||||
headerClassName: "hidden",
|
||||
cellClassName: "hidden",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
@@ -210,9 +205,9 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={businesses}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search businesses..."
|
||||
data={searchableBusinesses}
|
||||
searchKey="searchValue"
|
||||
searchPlaceholder="Search by name or nickname..."
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
|
||||
@@ -226,8 +221,8 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
business "{businessToDelete?.name}" and remove all associated
|
||||
data.
|
||||
business "{businessToDelete?.name}" and remove all
|
||||
associated data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import { BusinessForm } from "~/components/forms/business-form";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
|
||||
export default function NewBusinessPage() {
|
||||
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>
|
||||
<BusinessForm mode="create" />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
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 { 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
|
||||
async function BusinessesTable() {
|
||||
@@ -16,13 +16,13 @@ async function BusinessesTable() {
|
||||
|
||||
export default async function BusinessesPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="page-enter space-y-8">
|
||||
<PageHeader
|
||||
title="Businesses"
|
||||
description="Manage your businesses and their information"
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild className="btn-brand-primary shadow-md">
|
||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||
<Link href="/dashboard/businesses/new">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>Add Business</span>
|
||||
@@ -31,10 +31,10 @@ export default async function BusinessesPage() {
|
||||
</PageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={6} rows={5} />}>
|
||||
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
||||
<BusinessesTable />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
DollarSign,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
|
||||
interface ClientDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -67,7 +69,7 @@ export default async function ClientDetailPage({
|
||||
<span>Back to Clients</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="btn-brand-primary shadow-md">
|
||||
<Button asChild variant="default" className="shadow-md">
|
||||
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<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">
|
||||
{/* Client Information Card */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="card-primary">
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="bg-blue-subtle rounded-lg p-2">
|
||||
<Building className="text-icon-blue h-5 w-5" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Building className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<span>Contact Information</span>
|
||||
</CardTitle>
|
||||
@@ -92,8 +94,8 @@ export default async function ClientDetailPage({
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{client.email && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-green-subtle rounded-lg p-2">
|
||||
<Mail className="text-icon-green h-4 w-4" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Mail className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
@@ -106,8 +108,8 @@ export default async function ClientDetailPage({
|
||||
|
||||
{client.phone && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-green-subtle rounded-lg p-2">
|
||||
<Phone className="text-icon-green h-4 w-4" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Phone className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
@@ -124,8 +126,8 @@ export default async function ClientDetailPage({
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">Client Address</h3>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="bg-green-subtle rounded-lg p-2">
|
||||
<MapPin className="text-icon-green h-4 w-4" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<MapPin className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
{client.addressLine1 && (
|
||||
@@ -153,8 +155,8 @@ export default async function ClientDetailPage({
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">Client Details</h3>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-green-subtle rounded-lg p-2">
|
||||
<Calendar className="text-icon-green h-4 w-4" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Calendar className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
@@ -172,11 +174,11 @@ export default async function ClientDetailPage({
|
||||
|
||||
{/* Stats Card */}
|
||||
<div className="space-y-6">
|
||||
<Card className="card-primary">
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="bg-blue-subtle rounded-lg p-2">
|
||||
<DollarSign className="text-icon-blue h-5 w-5" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<DollarSign className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<span>Invoice Summary</span>
|
||||
</CardTitle>
|
||||
@@ -211,8 +213,8 @@ export default async function ClientDetailPage({
|
||||
<Card className="">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="bg-blue-subtle rounded-lg p-2">
|
||||
<DollarSign className="text-icon-blue h-5 w-5" />
|
||||
<div className="bg-primary/10 p-2">
|
||||
<DollarSign className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<span>Recent Invoices</span>
|
||||
</CardTitle>
|
||||
@@ -222,34 +224,50 @@ export default async function ClientDetailPage({
|
||||
{client.invoices.slice(0, 3).map((invoice) => (
|
||||
<div
|
||||
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>
|
||||
<p className="text-foreground font-medium">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-foreground font-medium break-words">
|
||||
{invoice.invoiceNumber}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{formatDate(invoice.issueDate)}
|
||||
</p>
|
||||
</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">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</p>
|
||||
<Badge
|
||||
variant={
|
||||
invoice.status === "paid"
|
||||
getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
) === "paid"
|
||||
? "default"
|
||||
: invoice.status === "sent"
|
||||
: getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
) === "sent"
|
||||
? "secondary"
|
||||
: getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
) === "overdue"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{invoice.status}
|
||||
{getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -90,13 +90,13 @@ export function ClientsDataTable({
|
||||
const client = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-status-info-muted hidden rounded-lg p-2 sm:flex">
|
||||
<UserPlus className="text-status-info h-4 w-4" />
|
||||
<div className="bg-primary/10 hidden p-2 sm:flex">
|
||||
<UserPlus className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{client.name}</p>
|
||||
<p className="text-muted-foreground truncate text-sm">
|
||||
{client.email || "—"}
|
||||
{client.email ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,7 +108,7 @@ export function ClientsDataTable({
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Phone" />
|
||||
),
|
||||
cell: ({ row }) => row.original.phone || "—",
|
||||
cell: ({ row }) => row.original.phone ?? "—",
|
||||
meta: {
|
||||
headerClassName: "hidden md:table-cell",
|
||||
cellClassName: "hidden md:table-cell",
|
||||
@@ -192,7 +192,8 @@ export function ClientsDataTable({
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
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>
|
||||
</DialogHeader>
|
||||
<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 { ClientsTable } from "./_components/clients-table";
|
||||
import Link from "next/link";
|
||||
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() {
|
||||
return (
|
||||
<>
|
||||
<div className="page-enter space-y-6">
|
||||
<PageHeader
|
||||
title="Clients"
|
||||
description="Manage your clients and their information."
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild className="btn-brand-primary shadow-md">
|
||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>Add Client</span>
|
||||
@@ -25,6 +24,6 @@ export default async function ClientsPage() {
|
||||
<HydrateClient>
|
||||
<ClientsTable />
|
||||
</HydrateClient>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
|
||||
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||
import { EXPENSE_CATEGORIES } from "~/server/api/routers/expenses";
|
||||
|
||||
interface ExpenseFormData {
|
||||
date: Date;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
category: string;
|
||||
billable: boolean;
|
||||
reimbursable: boolean;
|
||||
notes: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
const defaultForm: ExpenseFormData = {
|
||||
date: new Date(),
|
||||
description: "",
|
||||
amount: 0,
|
||||
currency: "USD",
|
||||
category: "",
|
||||
billable: false,
|
||||
reimbursable: false,
|
||||
notes: "",
|
||||
clientId: "",
|
||||
};
|
||||
|
||||
export default function ExpensesPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: expenses = [], isLoading } = api.expenses.getAll.useQuery();
|
||||
const { data: clients = [] } = api.clients.getAll.useQuery();
|
||||
|
||||
const create = api.expenses.create.useMutation({
|
||||
onSuccess: () => { toast.success("Expense added"); void utils.expenses.getAll.invalidate(); setOpen(false); setForm(defaultForm); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const update = api.expenses.update.useMutation({
|
||||
onSuccess: () => { toast.success("Expense updated"); void utils.expenses.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const del = api.expenses.delete.useMutation({
|
||||
onSuccess: () => { toast.success("Expense deleted"); void utils.expenses.getAll.invalidate(); setDeleteId(null); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const handleOpen = () => { setEditId(null); setForm(defaultForm); setOpen(true); };
|
||||
const handleEdit = (expense: typeof expenses[0]) => {
|
||||
setEditId(expense.id);
|
||||
setForm({
|
||||
date: new Date(expense.date),
|
||||
description: expense.description,
|
||||
amount: expense.amount,
|
||||
currency: expense.currency,
|
||||
category: expense.category ?? "",
|
||||
billable: expense.billable,
|
||||
reimbursable: expense.reimbursable,
|
||||
notes: expense.notes ?? "",
|
||||
clientId: expense.clientId ?? "",
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
if (!form.description.trim()) { toast.error("Description is required"); return; }
|
||||
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; }
|
||||
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined };
|
||||
if (editId) update.mutate({ id: editId, ...payload });
|
||||
else create.mutate(payload);
|
||||
};
|
||||
|
||||
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
||||
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0);
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6 pb-6">
|
||||
<PageHeader title="Expenses" description="Track billable and non-billable expenses" variant="gradient">
|
||||
<Button onClick={handleOpen} variant="default" className="hover-lift shadow-md">
|
||||
<Plus className="mr-2 h-5 w-5" /> Add Expense
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
|
||||
<p className="mt-1 text-2xl font-bold">{formatCurrency(totalExpenses)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Billable</p>
|
||||
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-2 sm:col-span-1">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
|
||||
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Expenses list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Receipt className="h-5 w-5" /> All Expenses
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">Loading…</div>
|
||||
) : expenses.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
||||
<p className="text-muted-foreground text-sm">No expenses yet. Add your first expense.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{expenses.map((expense) => (
|
||||
<div key={expense.id} className="flex items-start justify-between gap-3 p-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium">{expense.description}</p>
|
||||
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
|
||||
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>}
|
||||
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(expense.date))}
|
||||
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||
</p>
|
||||
{expense.notes && <p className="text-muted-foreground mt-1 text-xs">{expense.notes}</p>}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<p className="font-semibold">{formatCurrency(expense.amount, expense.currency)}</p>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(expense)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(expense.id)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add/Edit dialog */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Description *</Label>
|
||||
<Input value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} placeholder="e.g. Laptop charger" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Amount *</Label>
|
||||
<NumberInput value={form.amount} onChange={(v) => setForm((p) => ({ ...p, amount: v }))} min={0} step={0.01} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Currency</Label>
|
||||
<Select value={form.currency} onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{SUPPORTED_CURRENCIES.map((c) => <SelectItem key={c.code} value={c.code}>{c.code}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Date</Label>
|
||||
<DatePicker date={form.date} onDateChange={(d) => setForm((p) => ({ ...p, date: d ?? new Date() }))} className="w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select value={form.category || "none"} onValueChange={(v) => setForm((p) => ({ ...p, category: v === "none" ? "" : v }))}>
|
||||
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{EXPENSE_CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Client (optional)</Label>
|
||||
<Select value={form.clientId || "none"} onValueChange={(v) => setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))}>
|
||||
<SelectTrigger><SelectValue placeholder="No client" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No client</SelectItem>
|
||||
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
|
||||
<span className="text-sm">Billable</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
|
||||
<span className="text-sm">Reimbursable</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notes (optional)</Label>
|
||||
<Input value={form.notes} onChange={(e) => setForm((p) => ({ ...p, notes: e.target.value }))} placeholder="Additional details…" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
|
||||
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Add Expense"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete dialog */}
|
||||
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Expense</DialogTitle>
|
||||
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}>
|
||||
{del.isPending ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 { Skeleton } from "~/components/ui/skeleton";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
|
||||
export function InvoiceDetailsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 pb-24">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="bg-muted/30 h-8 w-48 sm:h-9 sm:w-64" />
|
||||
<Skeleton className="bg-muted/30 mt-1 h-4 w-40 sm:w-48" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="bg-muted/30 h-8 w-20 sm:h-9 sm:w-24" />
|
||||
<Skeleton className="bg-muted/30 h-8 w-16 sm:h-9 sm:w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Loading..."
|
||||
description="View and manage invoice information"
|
||||
variant="gradient"
|
||||
>
|
||||
<Skeleton className="h-10 w-10 sm:w-32" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</PageHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Invoice Header Skeleton */}
|
||||
<Card className="card-primary">
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<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="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="bg-muted/30 h-5 w-16 sm:h-6" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-1 sm:space-y-0">
|
||||
<Skeleton className="bg-muted/30 h-3 w-32 sm:h-4 sm:w-40" />
|
||||
<Skeleton className="bg-muted/30 h-3 w-28 sm:hidden sm:h-4 sm:w-36" />
|
||||
<div className="space-y-1 sm:space-y-0 text-sm">
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-32 hidden sm:block" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<Skeleton className="bg-muted/30 h-3 w-20 sm:h-4" />
|
||||
<Skeleton className="bg-muted/30 mt-1 h-6 w-24 sm:h-8 sm:w-28" />
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-left sm:text-right">
|
||||
<Skeleton className="h-4 w-24 mb-1 sm:ml-auto" />
|
||||
<Skeleton className="h-9 w-32 sm:ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,87 +48,107 @@ export function InvoiceDetailsSkeleton() {
|
||||
|
||||
{/* Client & Business Info */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Card key={i} className="card-primary">
|
||||
{/* Client Skeleton */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="bg-muted/30 h-4 w-4 sm:h-5 sm:w-5" />
|
||||
<Skeleton className="bg-muted/30 h-5 w-16 sm:h-6" />
|
||||
</div>
|
||||
<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="bg-muted/30 h-5 w-32 sm:h-6" />
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, j) => (
|
||||
<div key={j} className="flex items-center gap-3">
|
||||
<Skeleton className="bg-muted/30 h-8 w-8 rounded-lg" />
|
||||
<Skeleton className="bg-muted/30 h-4 w-28" />
|
||||
<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 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Invoice Items Skeleton */}
|
||||
<Card className="card-primary">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="bg-muted/30 h-4 w-4 sm:h-5 sm:w-5" />
|
||||
<Skeleton className="bg-muted/30 h-5 w-28 sm:h-6" />
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Item Rows */}
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<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">
|
||||
<Card key={i} className="bg-secondary/50 border-0">
|
||||
<CardContent className="p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="bg-muted/30 h-4 w-16" />
|
||||
<Skeleton className="bg-muted/30 h-4 w-20" />
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Skeleton className="h-5 w-3/4 mb-2" />
|
||||
<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 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>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Notes */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<Skeleton className="bg-muted/30 h-6 w-16" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="bg-muted/30 h-4 w-full" />
|
||||
<Skeleton className="bg-muted/30 h-4 w-3/4" />
|
||||
<Skeleton className="bg-muted/30 h-4 w-1/2" />
|
||||
{/* Totals */}
|
||||
<div className="bg-secondary rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -135,17 +156,18 @@ export function InvoiceDetailsSkeleton() {
|
||||
|
||||
{/* Right Column - Actions */}
|
||||
<div className="space-y-6">
|
||||
<Card className="card-primary sticky top-6">
|
||||
<Card className="sticky top-20">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="bg-muted/30 h-5 w-5" />
|
||||
<Skeleton className="bg-muted/30 h-6 w-16" />
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<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" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -40,13 +40,32 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
||||
accessorKey: "date",
|
||||
header: "Date",
|
||||
cell: ({ row }) => formatDate(row.getValue("date")),
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{row.getValue("description")}</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: plain description */}
|
||||
<div className="hidden font-medium sm:block">
|
||||
{item.description}
|
||||
</div>
|
||||
{/* Mobile: description + date + hours @ rate stacked */}
|
||||
<div className="sm:hidden">
|
||||
<p className="font-medium">{item.description}</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{formatDate(item.date)} · {item.hours}h @ {formatCurrency(item.rate)}/hr
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "hours",
|
||||
@@ -54,6 +73,10 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">{row.getValue("hours")}</div>
|
||||
),
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "rate",
|
||||
@@ -61,12 +84,16 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
|
||||
),
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: "Amount",
|
||||
cell: ({ row }) => (
|
||||
<div className="text-icon-emerald text-right font-medium">
|
||||
<div className="text-primary text-right font-medium">
|
||||
{formatCurrency(row.getValue("amount"))}
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
interface PDFDownloadButtonProps {
|
||||
invoiceId: string;
|
||||
variant?: "default" | "outline" | "ghost" | "icon";
|
||||
variant?: "default" | "outline" | "ghost" | "icon" | "secondary";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -77,12 +77,12 @@ export function PDFDownloadButton({
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
<span>Generating PDF...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-5 w-5" />
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
<span>Download PDF</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -4,27 +4,68 @@ 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;
|
||||
showResend?: boolean;
|
||||
}
|
||||
|
||||
export function SendInvoiceButton({
|
||||
invoiceId,
|
||||
variant = "outline",
|
||||
className,
|
||||
showResend = false,
|
||||
}: SendInvoiceButtonProps) {
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
// Fetch invoice data when sending is triggered
|
||||
const { refetch: fetchInvoice } = api.invoices.getById.useQuery(
|
||||
{ id: invoiceId },
|
||||
{ enabled: false },
|
||||
);
|
||||
// Get utils for cache invalidation
|
||||
const utils = api.useUtils();
|
||||
|
||||
// Use the new email API mutation
|
||||
const sendInvoiceMutation = api.email.sendInvoice.useMutation({
|
||||
onSuccess: (data) => {
|
||||
// Show detailed success message with delivery info
|
||||
toast.success(data.message, {
|
||||
description: `Email ID: ${data.emailId}`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
// Refresh invoice data to show updated status
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
},
|
||||
onError: (error) => {
|
||||
// Enhanced error handling with specific error types
|
||||
console.error("Email send error:", error);
|
||||
|
||||
let errorMessage = "Failed to send invoice email";
|
||||
let errorDescription = "";
|
||||
|
||||
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.";
|
||||
} else if (error.message.includes("no email address")) {
|
||||
errorMessage = "No Email Address";
|
||||
errorDescription = "This client doesn't have an email address on file.";
|
||||
} else {
|
||||
errorDescription = error.message;
|
||||
}
|
||||
|
||||
toast.error(errorMessage, {
|
||||
description: errorDescription,
|
||||
duration: 6000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSendInvoice = async () => {
|
||||
if (isSending) return;
|
||||
@@ -32,88 +73,12 @@ export function SendInvoiceButton({
|
||||
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");
|
||||
await sendInvoiceMutation.mutateAsync({
|
||||
invoiceId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Error is already handled by the mutation's onError
|
||||
console.error("Send invoice error:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to prepare invoice email",
|
||||
);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
@@ -149,12 +114,12 @@ ${invoice.business?.phone ? `\n${invoice.business.phone}` : ""}`;
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Preparing Email...</span>
|
||||
<span>Sending Email...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
<span>Send Invoice</span>
|
||||
<span>{showResend ? "Resend Invoice" : "Send Invoice"}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { InvoiceView } from "~/components/data/invoice-view";
|
||||
import { InvoiceForm } from "~/components/forms/invoice-form";
|
||||
import InvoiceForm from "~/components/forms/invoice-form";
|
||||
|
||||
interface UnifiedInvoicePageProps {
|
||||
invoiceId: string;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
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 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";
|
||||
import { notFound } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
import { DollarSign, Edit, Loader2, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { notFound, useParams, useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { PDFDownloadButton } from "./_components/pdf-download-button";
|
||||
import { SendInvoiceButton } from "./_components/send-invoice-button";
|
||||
import { Button } from "~/components/ui/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 { PDFDownloadButton } from "./_components/pdf-download-button";
|
||||
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Building,
|
||||
Edit,
|
||||
Check,
|
||||
FileText,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
User,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
|
||||
interface InvoicePageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
const router = useRouter();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
const invoice = await api.invoices.getById({ id: invoiceId });
|
||||
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
|
||||
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) {
|
||||
notFound();
|
||||
@@ -52,30 +109,34 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||
const total = subtotal + taxAmount;
|
||||
const isOverdue =
|
||||
new Date(invoice.dueDate) < new Date() && invoice.status !== "paid";
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
);
|
||||
const isOverdue = isInvoiceOverdue(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
);
|
||||
|
||||
const getStatusType = (): StatusType => {
|
||||
if (invoice.status === "paid") return "paid";
|
||||
if (invoice.status === "draft") return "draft";
|
||||
if (invoice.status === "overdue") return "overdue";
|
||||
if (invoice.status === "sent") {
|
||||
return isOverdue ? "overdue" : "sent";
|
||||
}
|
||||
return "draft";
|
||||
return effectiveStatus as StatusType;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-24">
|
||||
<div className="page-enter space-y-6 pb-24">
|
||||
<PageHeader
|
||||
title="Invoice Details"
|
||||
description="View and manage invoice information"
|
||||
variant="gradient"
|
||||
>
|
||||
<PDFDownloadButton invoiceId={invoice.id} variant="outline" />
|
||||
<Button asChild variant="default">
|
||||
<PDFDownloadButton
|
||||
invoiceId={invoice.id}
|
||||
variant="outline"
|
||||
className="hover-lift"
|
||||
/>
|
||||
<Button asChild variant="default" className="hover-lift">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="h-5 w-5" />
|
||||
<Edit className="mr-2 h-5 w-5" />
|
||||
<span>Edit</span>
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -86,13 +147,13 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
{/* Left Column */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Invoice Header */}
|
||||
<Card className="card-primary">
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<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="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}
|
||||
</h2>
|
||||
<StatusBadge status={getStatusType()} />
|
||||
@@ -106,7 +167,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
</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">
|
||||
Total Amount
|
||||
</p>
|
||||
@@ -121,7 +182,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{/* Overdue Alert */}
|
||||
{isOverdue && (
|
||||
<Card className="border-destructive/20 bg-destructive/5 card-secondary">
|
||||
<Card className="border-destructive/20 bg-destructive/5">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-destructive flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
||||
@@ -144,7 +205,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
{/* Client & Business Info */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Client Information */}
|
||||
<Card className="card-primary">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
@@ -161,7 +222,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
<div className="space-y-3">
|
||||
{invoice.client.email && (
|
||||
<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" />
|
||||
</div>
|
||||
<span className="text-sm break-all">
|
||||
@@ -172,7 +233,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{invoice.client.phone && (
|
||||
<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" />
|
||||
</div>
|
||||
<span className="text-sm">{invoice.client.phone}</span>
|
||||
@@ -181,7 +242,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{(invoice.client.addressLine1 ?? invoice.client.city) && (
|
||||
<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" />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
@@ -216,7 +277,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{/* Business Information */}
|
||||
{invoice.business && (
|
||||
<Card className="card-primary">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5" />
|
||||
@@ -233,7 +294,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
<div className="space-y-3">
|
||||
{invoice.business.email && (
|
||||
<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" />
|
||||
</div>
|
||||
<span className="text-sm break-all">
|
||||
@@ -244,7 +305,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{invoice.business.phone && (
|
||||
<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" />
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
@@ -259,7 +320,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
</div>
|
||||
|
||||
{/* Invoice Items */}
|
||||
<Card className="card-primary">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
@@ -267,48 +328,52 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invoice.items.map((item) => (
|
||||
<Card key={item.id} className="card-secondary">
|
||||
<CardContent className="py-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{invoice.items.map((item, _index) => (
|
||||
<Card key={item.id} className="invoice-item bg-secondary">
|
||||
<CardContent className="p-3">
|
||||
<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">
|
||||
<p className="text-foreground mb-2 text-base font-medium">
|
||||
<p className="text-foreground mb-2 text-base font-medium break-words">
|
||||
{item.description}
|
||||
</p>
|
||||
<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")}
|
||||
</span>
|
||||
<span className="inline whitespace-nowrap before:mx-2 before:content-['_|_']">
|
||||
<span className="whitespace-nowrap">
|
||||
{item.hours.toString().replace(/ /g, "\u00A0")}
|
||||
hours
|
||||
</span>
|
||||
<span className="inline whitespace-nowrap before:mx-2 before:content-['_|_']">
|
||||
<span className="whitespace-nowrap">
|
||||
@ ${item.rate}/hr
|
||||
</span>
|
||||
</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">
|
||||
{formatCurrency(item.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* 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="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="font-medium">
|
||||
{formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
{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">
|
||||
Tax ({invoice.taxRate}%):
|
||||
</span>
|
||||
@@ -318,7 +383,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
</div>
|
||||
)}
|
||||
<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 className="text-primary">
|
||||
{formatCurrency(total)}
|
||||
@@ -331,7 +396,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<Card className="card-primary">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notes</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -346,7 +411,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{/* Right Column - Actions */}
|
||||
<div className="space-y-6">
|
||||
<Card className="card-primary sticky top-6">
|
||||
<Card className="sticky top-20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Check className="h-5 w-5" />
|
||||
@@ -354,7 +419,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<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`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Invoice
|
||||
@@ -362,28 +427,117 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
</Button>
|
||||
|
||||
{invoice.items && invoice.client && (
|
||||
<PDFDownloadButton invoiceId={invoice.id} className="w-full" />
|
||||
<PDFDownloadButton
|
||||
invoiceId={invoice.id}
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
|
||||
{invoice.status === "draft" && (
|
||||
<SendInvoiceButton invoiceId={invoice.id} className="w-full" />
|
||||
{/* Send Invoice Button - Show for draft, sent, and overdue */}
|
||||
{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>
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function InvoicePage({ params }: InvoicePageProps) {
|
||||
const { id } = await params;
|
||||
export default function InvoiceViewPage() {
|
||||
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 (
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<InvoiceDetailsSkeleton />}>
|
||||
<InvoiceContent invoiceId={id} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <InvoiceViewContent invoiceId={id} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
taxRate: invoiceData.taxRate,
|
||||
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,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
})),
|
||||
}
|
||||
: undefined;
|
||||
}, [invoiceData]);
|
||||
|
||||
// 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);
|
||||
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: customMessage?.trim() || undefined,
|
||||
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="container mx-auto max-w-4xl p-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>Invoice not found.</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-6xl 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={customMessage}
|
||||
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>Send Invoice Email?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will send invoice #{invoice.invoiceNumber} to{" "}
|
||||
<strong>{invoice.client?.email}</strong>
|
||||
{ccEmail && (
|
||||
<>
|
||||
{" "}
|
||||
with CC to <strong>{ccEmail}</strong>
|
||||
</>
|
||||
)}
|
||||
{bccEmail && (
|
||||
<>
|
||||
{" "}
|
||||
and BCC to <strong>{bccEmail}</strong>
|
||||
</>
|
||||
)}
|
||||
.
|
||||
{retryCount > 0 && (
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
Retry attempt {retryCount} of 2
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={confirmSendEmail} variant="default">
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Send Email
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { ColumnDef, Row } from "@tanstack/react-table";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
|
||||
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 {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
@@ -20,32 +40,16 @@ interface Invoice {
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
taxRate: number;
|
||||
currency: string;
|
||||
notes: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
client?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
} | null;
|
||||
business?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
} | null;
|
||||
client?: { id: string; name: string; email: string | null; phone: string | null } | null;
|
||||
business?: { id: string; name: string; email: string | null; phone: string | null } | null;
|
||||
items?: Array<{
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
position: number;
|
||||
createdAt: Date;
|
||||
id: string; invoiceId: string; date: Date; description: string;
|
||||
hours: number; rate: number; amount: number; position: number; createdAt: Date;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
@@ -53,117 +57,129 @@ interface InvoicesDataTableProps {
|
||||
invoices: Invoice[];
|
||||
}
|
||||
|
||||
const getStatusType = (invoice: Invoice): StatusType => {
|
||||
if (invoice.status === "paid") return "paid";
|
||||
if (invoice.status === "draft") return "draft";
|
||||
if (invoice.status === "overdue") return "overdue";
|
||||
if (invoice.status === "sent") {
|
||||
const dueDate = new Date(invoice.dueDate);
|
||||
return dueDate < new Date() ? "overdue" : "sent";
|
||||
}
|
||||
return "draft";
|
||||
};
|
||||
const getStatusType = (invoice: Invoice): StatusType =>
|
||||
getEffectiveInvoiceStatus(invoice.status as StoredInvoiceStatus, invoice.dueDate) as StatusType;
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", { month: "short", day: "2-digit", year: "numeric" }).format(new Date(date));
|
||||
|
||||
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
const router = useRouter();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [pendingBulkDelete, setPendingBulkDelete] = useState<Invoice[]>([]);
|
||||
|
||||
const handleRowClick = (invoice: Invoice) => {
|
||||
router.push(`/dashboard/invoices/${invoice.id}`);
|
||||
};
|
||||
const utils = api.useUtils();
|
||||
|
||||
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>[] = [
|
||||
{
|
||||
accessorKey: "client.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Client" />
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
|
||||
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
|
||||
aria-label="Select all"
|
||||
data-action-button="true"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: Row<Invoice> }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||
aria-label="Select row"
|
||||
data-action-button="true"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "client.name",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Client" />,
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
return (
|
||||
<div className="max-w-[80px] min-w-0 sm:max-w-[200px] lg:max-w-[300px]">
|
||||
<p className="truncate font-medium">
|
||||
{invoice.client?.name ?? "—"}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
||||
{invoice.invoiceNumber}
|
||||
</p>
|
||||
<div className="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">{invoice.client?.name ?? "—"}</p>
|
||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">{invoice.invoiceNumber}</p>
|
||||
<div className="mt-1 flex items-center gap-2 sm:hidden">
|
||||
<StatusBadge status={getStatusType(invoice)} className="text-xs" />
|
||||
<span className="text-foreground text-xs font-semibold">
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "issueDate",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Date" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("issueDate");
|
||||
return (
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">{formatDate(date as Date)}</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
Due {formatDate(new Date(row.original.dueDate))}
|
||||
</p>
|
||||
<p className="truncate text-sm">{formatDate(row.getValue("issueDate") as Date)}</p>
|
||||
<p className="text-muted-foreground truncate text-xs">Due {formatDate(new Date(row.original.dueDate))}</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
status={getStatusType(row.original)}
|
||||
className={getStatusType(row.original) === "sent" ? "status-pending" : ""}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
return <StatusBadge status={getStatusType(invoice)} />;
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
const invoice = row.original;
|
||||
const status = getStatusType(invoice);
|
||||
return value.includes(status);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
filterFn: (row, _id, value: string[]) => value.includes(getStatusType(row.original)),
|
||||
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" },
|
||||
},
|
||||
{
|
||||
accessorKey: "totalAmount",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Amount" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const amount = row.getValue("totalAmount");
|
||||
return (
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold">
|
||||
{formatCurrency(amount as number)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{row.original.items?.length ?? 0} items
|
||||
{formatCurrency(row.getValue("totalAmount") as number, row.original.currency)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">{row.original.items?.length ?? 0} items</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
),
|
||||
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
@@ -172,25 +188,23 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true">
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost" size="sm"
|
||||
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 && (
|
||||
<div data-action-button="true">
|
||||
<PDFDownloadButton invoiceId={invoice.id} variant="icon" />
|
||||
@@ -216,13 +230,124 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={invoices}
|
||||
searchKey="invoiceNumber"
|
||||
searchPlaceholder="Search invoices..."
|
||||
filterableColumns={filterableColumns}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClick={(invoice) => router.push(`/dashboard/invoices/${invoice.id}`)}
|
||||
selectionActions={(selected, clear) => (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={bulkUpdateStatus.isPending}>
|
||||
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||
Mark as
|
||||
<ChevronDown className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
bulkUpdateStatus.mutate(
|
||||
{ ids: selected.map((i) => i.id), status: "sent" },
|
||||
{ onSuccess: clear },
|
||||
)
|
||||
}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" /> Mark Sent
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
bulkUpdateStatus.mutate(
|
||||
{ ids: selected.map((i) => i.id), status: "paid" },
|
||||
{ onSuccess: clear },
|
||||
)
|
||||
}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" /> Mark Paid
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
bulkUpdateStatus.mutate(
|
||||
{ ids: selected.map((i) => i.id), status: "draft" },
|
||||
{ onSuccess: clear },
|
||||
)
|
||||
}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" /> Mark Draft
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={bulkDelete.isPending}
|
||||
onClick={() => {
|
||||
setPendingBulkDelete(selected);
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete ({selected.length})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 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 {
|
||||
ArrowLeft,
|
||||
Upload,
|
||||
FileText,
|
||||
Download,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
Info,
|
||||
Upload,
|
||||
} 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
|
||||
function FormatInstructions() {
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Required Format */}
|
||||
<Card className="card-primary">
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="card-title-info">
|
||||
<FileText className="text-icon-blue h-5 w-5" />
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
Required CSV Format
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-muted-subtle rounded-lg p-4">
|
||||
<p className="text-secondary font-mono text-sm">
|
||||
<div className="bg-muted/50 p-4">
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
DATE,DESCRIPTION,HOURS,RATE,AMOUNT
|
||||
</p>
|
||||
</div>
|
||||
@@ -50,7 +49,7 @@ function FormatInstructions() {
|
||||
},
|
||||
].map((col) => (
|
||||
<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">
|
||||
{col.desc}
|
||||
</span>
|
||||
@@ -73,10 +72,10 @@ function FormatInstructions() {
|
||||
</Card>
|
||||
|
||||
{/* Sample Data & Download */}
|
||||
<Card className="card-primary">
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="card-title-secondary">
|
||||
<Download className="text-icon-green h-5 w-5" />
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<Download className="text-primary h-5 w-5" />
|
||||
Sample Template
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -86,9 +85,9 @@ function FormatInstructions() {
|
||||
for importing time entries.
|
||||
</p>
|
||||
|
||||
<div className="bg-green-subtle rounded-lg p-4">
|
||||
<div className="bg-primary/10 p-4">
|
||||
<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>
|
||||
<p className="text-success text-sm font-medium">Pro Tip</p>
|
||||
<p className="text-success text-sm">
|
||||
@@ -101,7 +100,7 @@ function FormatInstructions() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
1/15/24,"Web development work",8,75.00,600.00
|
||||
</p>
|
||||
@@ -110,7 +109,7 @@ function FormatInstructions() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,10 +122,10 @@ function FormatInstructions() {
|
||||
// Important Notes Section
|
||||
function ImportantNotes() {
|
||||
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>
|
||||
<CardTitle className="card-title-warning">
|
||||
<AlertCircle className="text-icon-amber h-5 w-5" />
|
||||
<CardTitle className="text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="text-primary h-5 w-5" />
|
||||
Important Notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -159,18 +158,18 @@ function ImportantNotes() {
|
||||
// File Format Help Section
|
||||
function FileFormatHelp() {
|
||||
return (
|
||||
<Card className="card-primary">
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="card-title-info">
|
||||
<FileSpreadsheet className="text-icon-blue h-5 w-5" />
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<FileSpreadsheet className="text-primary h-5 w-5" />
|
||||
Supported File Formats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<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="mx-auto w-fit rounded-full bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<FileSpreadsheet className="h-6 w-6 text-blue-600" />
|
||||
<div className="bg-accent mx-auto w-fit p-3">
|
||||
<FileSpreadsheet className="text-foreground-foreground h-6 w-6" />
|
||||
</div>
|
||||
<h4 className="font-semibold">CSV Files</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -179,8 +178,8 @@ function FileFormatHelp() {
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
<Upload className="h-6 w-6 text-green-600" />
|
||||
<div className="bg-primary/10 mx-auto w-fit p-3">
|
||||
<Upload className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<h4 className="font-semibold">Max Size</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -188,8 +187,8 @@ function FileFormatHelp() {
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
<CheckCircle className="h-6 w-6 text-purple-600" />
|
||||
<div className="bg-secondary mx-auto w-fit p-3">
|
||||
<CheckCircle className="text-muted-foreground-foreground h-6 w-6" />
|
||||
</div>
|
||||
<h4 className="font-semibold">Validation</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -1,719 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
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>
|
||||
);
|
||||
}
|
||||
import InvoiceForm from "~/components/forms/invoice-form";
|
||||
|
||||
export default function NewInvoicePage() {
|
||||
const router = useRouter();
|
||||
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>
|
||||
);
|
||||
return <InvoiceForm />;
|
||||
}
|
||||
|
||||
@@ -16,19 +16,19 @@ async function InvoicesTable() {
|
||||
|
||||
export default async function InvoicesPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="page-enter space-y-6">
|
||||
<PageHeader
|
||||
title="Invoices"
|
||||
description="Manage your invoices and track payments"
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild variant="outline" className="shadow-sm">
|
||||
<Button asChild variant="outline" className="hover-lift shadow-sm">
|
||||
<Link href="/dashboard/invoices/import">
|
||||
<Upload className="mr-2 h-5 w-5" />
|
||||
<span>Import CSV</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="btn-brand-primary shadow-md">
|
||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>Create Invoice</span>
|
||||
@@ -41,6 +41,6 @@ export default async function InvoicesPage() {
|
||||
<InvoicesTable />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "~/components/ui/tabs";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
|
||||
|
||||
interface TemplateForm {
|
||||
name: string;
|
||||
type: "notes" | "terms";
|
||||
content: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
const defaultForm: TemplateForm = { name: "", type: "notes", content: "", isDefault: false };
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<TemplateForm>(defaultForm);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<"notes" | "terms">("notes");
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: templates = [], isLoading } = api.invoiceTemplates.getAll.useQuery();
|
||||
|
||||
const create = api.invoiceTemplates.create.useMutation({
|
||||
onSuccess: () => { toast.success("Template created"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setForm(defaultForm); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const update = api.invoiceTemplates.update.useMutation({
|
||||
onSuccess: () => { toast.success("Template updated"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const del = api.invoiceTemplates.delete.useMutation({
|
||||
onSuccess: () => { toast.success("Template deleted"); void utils.invoiceTemplates.getAll.invalidate(); setDeleteId(null); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const handleOpen = (type: "notes" | "terms") => {
|
||||
setEditId(null);
|
||||
setForm({ ...defaultForm, type });
|
||||
setOpen(true);
|
||||
};
|
||||
const handleEdit = (t: typeof templates[0]) => {
|
||||
setEditId(t.id);
|
||||
setForm({ name: t.name, type: t.type as "notes" | "terms", content: t.content, isDefault: t.isDefault });
|
||||
setOpen(true);
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
if (!form.name.trim()) { toast.error("Name is required"); return; }
|
||||
if (!form.content.trim()) { toast.error("Content is required"); return; }
|
||||
if (editId) update.mutate({ id: editId, ...form });
|
||||
else create.mutate(form);
|
||||
};
|
||||
|
||||
const notesTemplates = templates.filter((t) => t.type === "notes");
|
||||
const termsTemplates = templates.filter((t) => t.type === "terms");
|
||||
|
||||
const TemplateList = ({ items, type }: { items: typeof templates; type: "notes" | "terms" }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => handleOpen(type)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" /> New {type === "notes" ? "Notes" : "Terms"} Template
|
||||
</Button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">Loading…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
No {type} templates yet.
|
||||
</div>
|
||||
) : (
|
||||
items.map((t) => (
|
||||
<Card key={t.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{t.name}</p>
|
||||
{t.isDefault && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Star className="mr-1 h-3 w-3" /> Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap">
|
||||
{t.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(t)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(t.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6 pb-6">
|
||||
<PageHeader
|
||||
title="Invoice Templates"
|
||||
description="Reusable notes and payment terms for your invoices"
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="notes">
|
||||
<FileText className="mr-1.5 h-4 w-4" /> Notes ({notesTemplates.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="terms">
|
||||
<FileText className="mr-1.5 h-4 w-4" /> Terms ({termsTemplates.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="notes" className="mt-4">
|
||||
<TemplateList items={notesTemplates} type="notes" />
|
||||
</TabsContent>
|
||||
<TabsContent value="terms" className="mt-4">
|
||||
<TemplateList items={termsTemplates} type="terms" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Create/Edit dialog */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editId ? "Edit Template" : "New Template"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Name *</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Standard Payment Terms" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Tabs value={form.type} onValueChange={(v) => setForm((p) => ({ ...p, type: v as "notes" | "terms" }))}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||
<TabsTrigger value="terms">Terms</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Content *</Label>
|
||||
<Textarea
|
||||
value={form.content}
|
||||
onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))}
|
||||
placeholder="Template content…"
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox checked={form.isDefault} onCheckedChange={(v) => setForm((p) => ({ ...p, isDefault: !!v }))} />
|
||||
<span className="text-sm">Set as default for {form.type}</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
|
||||
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete dialog */}
|
||||
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Template</DialogTitle>
|
||||
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}>
|
||||
{del.isPending ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,9 @@
|
||||
import { Navbar } from "~/components/layout/navbar";
|
||||
import { Sidebar } from "~/components/layout/sidebar";
|
||||
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
|
||||
import { DashboardShell } from "~/components/layout/dashboard-shell";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <DashboardShell>{children}</DashboardShell>;
|
||||
}
|
||||
|
||||
+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 {
|
||||
Users,
|
||||
FileText,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Plus,
|
||||
ArrowUpRight,
|
||||
Calendar,
|
||||
Clock,
|
||||
Eye,
|
||||
Edit,
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Edit,
|
||||
Eye,
|
||||
FileText,
|
||||
Plus,
|
||||
Users,
|
||||
} 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
|
||||
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>
|
||||
);
|
||||
}
|
||||
// Hero section with clean mono design
|
||||
|
||||
// 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;
|
||||
const totalInvoices = invoices.length;
|
||||
const totalRevenue = invoices
|
||||
.filter((invoice) => invoice.status === "paid")
|
||||
.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
||||
const pendingAmount = invoices
|
||||
.filter((invoice) => invoice.status === "sent")
|
||||
.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
||||
// Enhanced stats cards with better visuals
|
||||
function DashboardStats({ stats }: { stats: DashboardStats }) { // TODO: Import RouterOutput type
|
||||
const formatTrend = (value: number, isCount = false) => {
|
||||
if (isCount) {
|
||||
return value > 0 ? `+${value}` : value.toString();
|
||||
}
|
||||
return value > 0 ? `+${value.toFixed(1)}%` : `${value.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const stats = [
|
||||
const statCards = [
|
||||
{
|
||||
title: "Total Revenue",
|
||||
value: `$${totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||
change: "+12.5%",
|
||||
icon: DollarSign,
|
||||
color: "",
|
||||
bgColor: "bg-green-50",
|
||||
changeColor: "",
|
||||
value: `$${stats.totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||
numericValue: stats.totalRevenue,
|
||||
isCurrency: true,
|
||||
change: formatTrend(stats.revenueChange),
|
||||
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const),
|
||||
iconName: "DollarSign" as const,
|
||||
description: "Total collected revenue",
|
||||
},
|
||||
{
|
||||
title: "Pending Amount",
|
||||
value: `$${pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||
change: "+8.2%",
|
||||
icon: Clock,
|
||||
color: "",
|
||||
bgColor: "bg-amber-50",
|
||||
changeColor: "",
|
||||
value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||
numericValue: stats.pendingAmount,
|
||||
isCurrency: true,
|
||||
change: "0%", // TODO: Calculate pending change if needed
|
||||
trend: "neutral" as const,
|
||||
iconName: "Clock" as const,
|
||||
description: "Invoices awaiting payment",
|
||||
},
|
||||
{
|
||||
title: "Active Clients",
|
||||
value: totalClients.toString(),
|
||||
change: "+3",
|
||||
icon: Users,
|
||||
color: "",
|
||||
bgColor: "bg-blue-50",
|
||||
changeColor: "",
|
||||
value: stats.totalClients.toString(),
|
||||
numericValue: stats.totalClients,
|
||||
isCurrency: false,
|
||||
change: "0", // TODO: Calculate client change if needed
|
||||
trend: "neutral" as const,
|
||||
iconName: "Users" as const,
|
||||
description: "Total registered clients",
|
||||
},
|
||||
{
|
||||
title: "Total Invoices",
|
||||
value: totalInvoices.toString(),
|
||||
change: "+15",
|
||||
icon: FileText,
|
||||
color: "",
|
||||
bgColor: "bg-purple-50",
|
||||
changeColor: "",
|
||||
title: "Overdue Invoices",
|
||||
value: stats.overdueCount.toString(),
|
||||
numericValue: stats.overdueCount,
|
||||
isCurrency: false,
|
||||
change: "0", // TODO: Calculate overdue change if needed
|
||||
trend: "neutral" as const,
|
||||
iconName: "TrendingDown" as const,
|
||||
description: "Invoices past due date",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mb-8 grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Card
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{statCards.map((stat, index) => (
|
||||
<AnimatedStatsCard
|
||||
key={stat.title}
|
||||
className="border-0 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<CardContent className="p-3 sm:p-4 lg:p-6">
|
||||
<div className="mb-2 flex items-center justify-between sm:mb-3 lg:mb-4">
|
||||
<div className={`rounded-lg p-1.5 sm:p-2 ${stat.bgColor}`}>
|
||||
<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" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-teal-600 dark:text-teal-400">
|
||||
{stat.change}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
title={stat.title}
|
||||
value={stat.value}
|
||||
numericValue={stat.numericValue}
|
||||
isCurrency={stat.isCurrency}
|
||||
iconName={stat.iconName}
|
||||
change={stat.change}
|
||||
trend={stat.trend}
|
||||
description={stat.description}
|
||||
delay={index * 100}
|
||||
/>
|
||||
))}
|
||||
</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() {
|
||||
const actions = [
|
||||
{
|
||||
title: "Create Invoice",
|
||||
description: "Start a new invoice",
|
||||
description: "Start a new invoice for a client",
|
||||
href: "/dashboard/invoices/new",
|
||||
icon: FileText,
|
||||
primary: true,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
title: "Add Client",
|
||||
description: "Add a new client",
|
||||
description: "Register a new client",
|
||||
href: "/dashboard/clients/new",
|
||||
icon: Users,
|
||||
primary: false,
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
title: "View Reports",
|
||||
description: "Business analytics",
|
||||
href: "/dashboard/reports",
|
||||
title: "View All Invoices",
|
||||
description: "Manage your invoice pipeline",
|
||||
href: "/dashboard/invoices",
|
||||
icon: BarChart3,
|
||||
primary: false,
|
||||
featured: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Plus className="h-5 w-5 text-teal-600 dark:text-teal-400" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<CardContent className="space-y-3">
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Button
|
||||
<Link
|
||||
key={action.title}
|
||||
asChild
|
||||
variant={action.primary ? "default" : "outline"}
|
||||
className={`h-12 w-full justify-start px-3 ${
|
||||
action.primary
|
||||
? "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"
|
||||
href={action.href}
|
||||
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${action.featured
|
||||
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
||||
: "border-border bg-background hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Link href={action.href}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon
|
||||
className={`h-4 w-4 ${action.primary ? "text-white" : "text-gray-600 dark:text-gray-300"}`}
|
||||
/>
|
||||
<span
|
||||
className={`font-medium ${action.primary ? "text-white" : "text-gray-900 dark:text-gray-100"}`}
|
||||
>
|
||||
{action.title}
|
||||
</span>
|
||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold">{action.title}</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
@@ -195,30 +213,35 @@ function QuickActions() {
|
||||
);
|
||||
}
|
||||
|
||||
// Current work in progress
|
||||
// Current work section with enhanced design
|
||||
async function CurrentWork() {
|
||||
const invoices = await api.invoices.getAll();
|
||||
const draftInvoices = invoices.filter(
|
||||
(invoice) => invoice.status === "draft",
|
||||
(invoice) =>
|
||||
getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
) === "draft",
|
||||
);
|
||||
const currentInvoice = draftInvoices[0];
|
||||
|
||||
if (!currentInvoice) {
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Activity className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Current Work
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="py-8 text-center">
|
||||
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-300 dark:text-gray-600" />
|
||||
<p className="mb-4 text-gray-600 dark:text-gray-300">
|
||||
No draft invoices found
|
||||
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">No active drafts</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Create a new invoice to get started
|
||||
</p>
|
||||
<Button asChild className="bg-teal-600 hover:bg-teal-700">
|
||||
<Button asChild variant="outline" className="border-foreground/20">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Invoice
|
||||
@@ -234,49 +257,48 @@ async function CurrentWork() {
|
||||
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Activity className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Current Work
|
||||
</CardTitle>
|
||||
<Badge variant="secondary">In Progress</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-lg font-semibold">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 className="text-lg font-semibold break-words">
|
||||
#{currentInvoice.invoiceNumber}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{currentInvoice.client?.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold text-teal-600 dark:text-teal-400">
|
||||
</h3>
|
||||
<span className="text-primary text-2xl font-bold">
|
||||
${currentInvoice.totalAmount.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{totalHours.toFixed(1)} hours
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="break-words">{currentInvoice.client?.name}</span>
|
||||
<span className="text-xs sm:text-sm">
|
||||
{totalHours.toFixed(1)} hours logged
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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}`}>
|
||||
<Eye className="mr-2 h-3 w-3" />
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="flex-1 bg-teal-600 hover:bg-teal-700"
|
||||
>
|
||||
<Button asChild size="sm" className="hover-lift flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-3 w-3" />
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Continue
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -287,51 +309,62 @@ async function CurrentWork() {
|
||||
);
|
||||
}
|
||||
|
||||
// Recent activity with enhanced design
|
||||
async function RecentActivity() {
|
||||
const invoices = await api.invoices.getAll();
|
||||
const recentInvoices = invoices
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.issueDate).getTime() - new Date(a.issueDate).getTime(),
|
||||
)
|
||||
.slice(0, 5);
|
||||
// Enhanced recent activity
|
||||
async function RecentActivity({ recentInvoices }: { recentInvoices: RecentInvoice[] }) {
|
||||
// Use passed recentInvoices instead of fetching all
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const getStatusStyle = (status: string) => {
|
||||
switch (status) {
|
||||
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":
|
||||
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":
|
||||
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:
|
||||
return "bg-gray-50 border-gray-200";
|
||||
return {
|
||||
backgroundColor: "hsl(var(--muted))",
|
||||
borderColor: "hsl(var(--border))",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Calendar className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/dashboard/invoices">
|
||||
View All
|
||||
<ArrowUpRight className="ml-1 h-4 w-4" />
|
||||
<span className="hidden sm:inline">View All</span>
|
||||
<ArrowUpRight className="h-4 w-4 sm:ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentInvoices.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-300 dark:text-gray-600" />
|
||||
<p className="mb-4 text-gray-600 dark:text-gray-300">
|
||||
No invoices yet
|
||||
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">No invoices yet</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Create your first invoice to get started
|
||||
</p>
|
||||
<Button asChild className="bg-teal-600 hover:bg-teal-700">
|
||||
<Button asChild variant="outline" className="border-foreground/20">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Your First Invoice
|
||||
@@ -340,45 +373,42 @@ async function RecentActivity() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentInvoices.map((invoice) => (
|
||||
{recentInvoices.map((invoice, _index) => (
|
||||
<Link
|
||||
key={invoice.id}
|
||||
href={`/dashboard/invoices/${invoice.id}`}
|
||||
className="block"
|
||||
>
|
||||
<Card className="card-secondary transition-colors hover:bg-gray-200/70 dark:hover:bg-gray-700/60">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<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 className="recent-activity-item bg-muted/50 hover:bg-muted border-foreground/20 rounded-lg border p-3 transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-muted flex-shrink-0 rounded-lg p-2">
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<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}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{invoice.client?.name} •{" "}
|
||||
<p className="text-muted-foreground truncate text-sm">
|
||||
{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()}
|
||||
</p>
|
||||
</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 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>
|
||||
))}
|
||||
</div>
|
||||
@@ -391,16 +421,16 @@ async function RecentActivity() {
|
||||
// Loading skeletons
|
||||
function StatsSkeleton() {
|
||||
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) => (
|
||||
<Card key={i} className="border-0 shadow-sm">
|
||||
<CardContent className="p-3 sm:p-4 lg:p-6">
|
||||
<div className="mb-2 flex items-center justify-between sm:mb-3 lg:mb-4">
|
||||
<Skeleton className="h-6 w-6 rounded-lg sm:h-8 sm:w-8 lg:h-9 lg:w-9" />
|
||||
<Skeleton className="h-3 w-8 sm:h-4 sm:w-12" />
|
||||
<Card key={i}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
<Skeleton className="mb-1 h-5 w-16 sm:mb-2 sm:h-6 sm:w-20 lg:h-8" />
|
||||
<Skeleton className="h-3 w-20 sm:h-4 sm:w-24" />
|
||||
<Skeleton className="mb-2 h-8 w-20" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</CardContent>
|
||||
</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() {
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
@@ -425,35 +486,54 @@ function CardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
|
||||
// ... imports
|
||||
|
||||
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";
|
||||
|
||||
// Fetch stats centrally
|
||||
const stats = await api.dashboard.getStats();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<DashboardHero firstName={firstName} />
|
||||
<div className="page-enter space-y-6">
|
||||
<DashboardPageHeader
|
||||
title={`Welcome back, ${firstName}!`}
|
||||
description="Here's what's happening with your business today"
|
||||
/>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<StatsSkeleton />}>
|
||||
<DashboardStats />
|
||||
<DashboardStats stats={stats} />
|
||||
</Suspense>
|
||||
</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>
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<CurrentWork />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
|
||||
<QuickActions />
|
||||
</div>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<RecentActivity />
|
||||
<RecentActivity recentInvoices={stats.recentInvoices} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { StatusBadge } from "~/components/data/status-badge";
|
||||
import { formatCurrency } from "~/lib/currency";
|
||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { TrendingUp, DollarSign, Clock, Users } from "lucide-react";
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { data: invoices = [], isLoading } = api.invoices.getAll.useQuery();
|
||||
const { data: stats } = api.dashboard.getStats.useQuery();
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const reportData = useMemo(() => {
|
||||
if (!invoices.length) return null;
|
||||
|
||||
// Revenue by month (last 12 months)
|
||||
const monthMap: Record<string, number> = {};
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
monthMap[key] = 0;
|
||||
}
|
||||
|
||||
let totalRevenue = 0;
|
||||
let totalPending = 0;
|
||||
let totalHours = 0;
|
||||
let overdueCount = 0;
|
||||
|
||||
for (const inv of invoices) {
|
||||
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||
if (status === "paid") {
|
||||
totalRevenue += inv.totalAmount;
|
||||
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`;
|
||||
if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount;
|
||||
} else if (status === "sent" || status === "overdue") {
|
||||
totalPending += inv.totalAmount;
|
||||
}
|
||||
if (status === "overdue") overdueCount++;
|
||||
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
|
||||
}
|
||||
|
||||
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
|
||||
month: new Date(month + "-01").toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
|
||||
revenue,
|
||||
}));
|
||||
|
||||
// Top clients by revenue (paid only)
|
||||
const clientMap: Record<string, { name: string; revenue: number; count: number }> = {};
|
||||
for (const inv of invoices) {
|
||||
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||
if (status === "paid" && inv.client) {
|
||||
const id = inv.client.id;
|
||||
if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0, count: 0 };
|
||||
clientMap[id]!.revenue += inv.totalAmount;
|
||||
clientMap[id]!.count += 1;
|
||||
}
|
||||
}
|
||||
const topClients = Object.values(clientMap)
|
||||
.sort((a, b) => b.revenue - a.revenue)
|
||||
.slice(0, 6);
|
||||
|
||||
// Status breakdown
|
||||
const statusCount: Record<string, number> = { draft: 0, sent: 0, paid: 0, overdue: 0 };
|
||||
for (const inv of invoices) {
|
||||
const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, overdueCount, statusCount };
|
||||
}, [invoices]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="page-enter space-y-6">
|
||||
<PageHeader title="Reports" description="Revenue and invoice analytics" variant="gradient" />
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const avgInvoice = invoices.length > 0 ? (reportData?.totalRevenue ?? 0) / invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 0 : 0;
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6 pb-6">
|
||||
<PageHeader title="Reports" description="Revenue and invoice analytics" variant="gradient" />
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 rounded p-1.5">
|
||||
<DollarSign className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(reportData?.totalRevenue ?? 0)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-yellow-500/10 rounded p-1.5">
|
||||
<Clock className="h-4 w-4 text-yellow-500" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs font-medium">Pending</p>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(reportData?.totalPending ?? 0)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-blue-500/10 rounded p-1.5">
|
||||
<TrendingUp className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-green-500/10 rounded p-1.5">
|
||||
<Users className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs font-medium">Total Hours</p>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">{(reportData?.totalHours ?? 0).toFixed(1)}h</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Revenue trend chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-48 w-full md:h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={reportData?.revenueByMonth ?? []}>
|
||||
<defs>
|
||||
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
||||
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
||||
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Top clients */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" /> Top Clients by Revenue
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!reportData?.topClients.length ? (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p>
|
||||
) : (
|
||||
<div className="h-48 md:h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={reportData.topClients} layout="vertical">
|
||||
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} />
|
||||
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
||||
<Bar dataKey="revenue" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice status breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invoice Status Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Object.entries(reportData?.statusCount ?? {}).map(([status, count]) => (
|
||||
<div key={status} className="flex items-center justify-between">
|
||||
<StatusBadge status={status as never} />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full"
|
||||
style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{invoices.length === 0 && (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Monthly stats table */}
|
||||
{stats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="divide-y">
|
||||
{stats.recentInvoices.map((inv) => (
|
||||
<div key={inv.id} className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="font-medium">{inv.client?.name ?? "—"}</p>
|
||||
<p className="text-muted-foreground text-xs">{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate) as never} />
|
||||
<p className="font-semibold">{formatCurrency(inv.totalAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import * as React from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import {
|
||||
Download,
|
||||
Upload,
|
||||
User,
|
||||
Database,
|
||||
AlertTriangle,
|
||||
Shield,
|
||||
FileText,
|
||||
Users,
|
||||
Building,
|
||||
Key,
|
||||
ChevronDown,
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
FileUp,
|
||||
Info,
|
||||
Key,
|
||||
Palette,
|
||||
Shield,
|
||||
Upload,
|
||||
User,
|
||||
Users,
|
||||
Link as LinkIcon,
|
||||
} from "lucide-react";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -43,6 +35,19 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "~/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -52,13 +57,23 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Slider } from "~/components/ui/slider";
|
||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
|
||||
export function SettingsContent() {
|
||||
const { data: session } = useSession();
|
||||
const { data: session } = authClient.useSession();
|
||||
// const session = { user: null } as any;
|
||||
const [name, setName] = useState("");
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||
const [importData, setImportData] = useState("");
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
const [importMethod, setImportMethod] = useState<"file" | "paste">("file");
|
||||
|
||||
// Password change state
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
@@ -67,6 +82,39 @@ export function SettingsContent() {
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLinking, setIsLinking] = useState(false);
|
||||
|
||||
const handleLinkAuthentik = async () => {
|
||||
setIsLinking(true);
|
||||
try {
|
||||
await authClient.signIn.oauth2({
|
||||
providerId: "authentik",
|
||||
callbackURL: "/dashboard/settings",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Failed to link account");
|
||||
setIsLinking(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Animation preferences via provider (centralized)
|
||||
const {
|
||||
prefersReducedMotion,
|
||||
animationSpeedMultiplier,
|
||||
updatePreferences,
|
||||
isUpdating: animationPrefsUpdating,
|
||||
setPrefersReducedMotion,
|
||||
setAnimationSpeedMultiplier,
|
||||
} = useAnimationPreferences();
|
||||
|
||||
const handleSaveAnimationPreferences = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updatePreferences({
|
||||
prefersReducedMotion,
|
||||
animationSpeedMultiplier,
|
||||
});
|
||||
toast.success("Animation preferences updated");
|
||||
};
|
||||
|
||||
// Queries
|
||||
const { data: profile, refetch: refetchProfile } =
|
||||
@@ -100,10 +148,9 @@ export function SettingsContent() {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
// Handle export data success/error
|
||||
React.useEffect(() => {
|
||||
if (exportDataQuery.data && !exportDataQuery.isFetching) {
|
||||
const blob = new Blob([JSON.stringify(exportDataQuery.data, null, 2)], {
|
||||
// Handle download logic
|
||||
const handleDownload = React.useCallback((data: unknown) => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -116,12 +163,7 @@ export function SettingsContent() {
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Data backup downloaded successfully");
|
||||
}
|
||||
|
||||
if (exportDataQuery.error) {
|
||||
toast.error(`Export failed: ${exportDataQuery.error.message}`);
|
||||
}
|
||||
}, [exportDataQuery.data, exportDataQuery.isFetching, exportDataQuery.error]);
|
||||
}, []);
|
||||
|
||||
const importDataMutation = api.settings.importData.useMutation({
|
||||
onSuccess: (result) => {
|
||||
@@ -177,81 +219,74 @@ export function SettingsContent() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportData = () => {
|
||||
void exportDataQuery.refetch();
|
||||
const handleExportData = async () => {
|
||||
try {
|
||||
const result = await exportDataQuery.refetch();
|
||||
if (result.data) {
|
||||
handleDownload(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Type guard for backup data
|
||||
const isValidBackupData = (
|
||||
data: unknown,
|
||||
): data is {
|
||||
exportDate: string;
|
||||
version: string;
|
||||
user: { name?: string; email: string };
|
||||
clients: Array<{
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
addressLine1?: string;
|
||||
addressLine2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
}>;
|
||||
businesses: Array<{
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
addressLine1?: string;
|
||||
addressLine2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
website?: string;
|
||||
taxId?: string;
|
||||
logoUrl?: string;
|
||||
isDefault?: boolean;
|
||||
}>;
|
||||
invoices: Array<{
|
||||
invoiceNumber: string;
|
||||
businessName?: string;
|
||||
clientName: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status?: string;
|
||||
totalAmount?: number;
|
||||
taxRate?: number;
|
||||
notes?: string;
|
||||
items: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
position?: number;
|
||||
}>;
|
||||
}>;
|
||||
} => {
|
||||
const isValidBackupData = (data: unknown): boolean => {
|
||||
if (typeof data !== "object" || data === null) return false;
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
return !!(
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
"exportDate" in data &&
|
||||
"version" in data &&
|
||||
"user" in data &&
|
||||
"clients" in data &&
|
||||
"businesses" in data &&
|
||||
"invoices" in data
|
||||
obj.exportDate &&
|
||||
obj.version &&
|
||||
obj.user &&
|
||||
obj.clients &&
|
||||
obj.businesses &&
|
||||
obj.invoices &&
|
||||
Array.isArray(obj.clients) &&
|
||||
Array.isArray(obj.businesses) &&
|
||||
Array.isArray(obj.invoices)
|
||||
);
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.name.endsWith(".json")) {
|
||||
toast.error("Please select a JSON file");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const parsedData: unknown = JSON.parse(content);
|
||||
|
||||
if (isValidBackupData(parsedData)) {
|
||||
// @ts-expect-error Server handles validation of backup data format
|
||||
importDataMutation.mutate(parsedData);
|
||||
} else {
|
||||
toast.error("Invalid backup file format");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Invalid JSON format. Please check your backup file.");
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleImportData = () => {
|
||||
try {
|
||||
const parsedData: unknown = JSON.parse(importData);
|
||||
|
||||
if (isValidBackupData(parsedData)) {
|
||||
// @ts-expect-error Server handles validation of backup data format
|
||||
importDataMutation.mutate(parsedData);
|
||||
} else {
|
||||
toast.error("Invalid backup file format");
|
||||
@@ -274,41 +309,52 @@ export function SettingsContent() {
|
||||
if (profile?.name && !name) {
|
||||
setName(profile.name);
|
||||
}
|
||||
}, [profile?.name, name]);
|
||||
if (session?.user) {
|
||||
setName(session.user.name ?? "");
|
||||
}
|
||||
}, [session, profile?.name, name]);
|
||||
|
||||
// (Removed direct DOM mutation; provider handles applying preferences globally)
|
||||
|
||||
const dataStatItems = [
|
||||
{
|
||||
label: "Clients",
|
||||
value: dataStats?.clients ?? 0,
|
||||
icon: Users,
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-50 dark:bg-blue-900/20",
|
||||
color: "text-primary",
|
||||
bgColor: "bg-primary/10",
|
||||
},
|
||||
{
|
||||
label: "Businesses",
|
||||
value: dataStats?.businesses ?? 0,
|
||||
icon: Building,
|
||||
color: "text-purple-600",
|
||||
bgColor: "bg-purple-50 dark:bg-purple-900/20",
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted",
|
||||
},
|
||||
{
|
||||
label: "Invoices",
|
||||
value: dataStats?.invoices ?? 0,
|
||||
icon: FileText,
|
||||
color: "text-emerald-600",
|
||||
bgColor: "bg-emerald-50 dark:bg-emerald-900/20",
|
||||
color: "text-primary",
|
||||
bgColor: "bg-accent",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Profile & Account Overview */}
|
||||
<Tabs defaultValue="general" className="space-y-4">
|
||||
<TabsList className="bg-muted/50 grid w-full grid-cols-3 lg:w-[400px]">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="preferences">Preferences</TabsTrigger>
|
||||
<TabsTrigger value="data">Data</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="space-y-8">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Profile Section */}
|
||||
<Card className="card-primary">
|
||||
<Card className="form-section bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="card-title-secondary">
|
||||
<User className="text-icon-blue h-5 w-5" />
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<User className="text-primary h-5 w-5" />
|
||||
Profile Information
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -341,7 +387,8 @@ export function SettingsContent() {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateProfileMutation.isPending}
|
||||
className="btn-brand-primary"
|
||||
variant="default"
|
||||
className="hover-lift"
|
||||
>
|
||||
{updateProfileMutation.isPending
|
||||
? "Updating..."
|
||||
@@ -351,51 +398,11 @@ export function SettingsContent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Data Overview */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="card-title-info">
|
||||
<Database className="text-icon-blue h-5 w-5" />
|
||||
Account Data
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Overview of your stored information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{dataStatItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className="bg-card flex items-center justify-between rounded-lg border p-4 transition-shadow hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`rounded-lg p-2 ${item.bgColor}`}>
|
||||
<Icon className={`h-4 w-4 ${item.color}`} />
|
||||
</div>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{item.value}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Security Settings */}
|
||||
<Card className="card-primary">
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="card-title-secondary">
|
||||
<Key className="text-icon-amber h-5 w-5" />
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<Key className="text-primary h-5 w-5" />
|
||||
Security Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -488,7 +495,7 @@ export function SettingsContent() {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={changePasswordMutation.isPending}
|
||||
className="btn-brand-primary"
|
||||
variant="default"
|
||||
>
|
||||
{changePasswordMutation.isPending
|
||||
? "Changing Password..."
|
||||
@@ -498,11 +505,191 @@ export function SettingsContent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Data Management */}
|
||||
<Card className="card-primary">
|
||||
{/* Connected Accounts */}
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="card-title-secondary">
|
||||
<Shield className="text-icon-indigo h-5 w-5" />
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<LinkIcon className="text-primary h-5 w-5" />
|
||||
Connected Accounts
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your linked social accounts and SSO providers
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10">
|
||||
<Shield className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium leading-none">Authentik SSO</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Connect your corporate account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isLinking}
|
||||
onClick={handleLinkAuthentik}
|
||||
>
|
||||
{isLinking ? "Connecting..." : "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="preferences" className="space-y-8">
|
||||
{/* Theme follows system preferences automatically via CSS media queries */}
|
||||
|
||||
{/* Accessibility & Animation */}
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<Info className="text-primary h-5 w-5" />
|
||||
Accessibility & Animation
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSaveAnimationPreferences} className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Reduce Motion</Label>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
Turn this on to reduce or remove non-essential animations and
|
||||
transitions.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={prefersReducedMotion}
|
||||
onCheckedChange={(checked) =>
|
||||
setPrefersReducedMotion(Boolean(checked))
|
||||
}
|
||||
aria-label="Toggle reduced motion"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="mb-0">Animation Speed Multiplier</Label>
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{prefersReducedMotion
|
||||
? "1.00x (locked)"
|
||||
: `${animationSpeedMultiplier.toFixed(2)}x`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
Adjust global animation duration scaling. Lower values (0.25×,
|
||||
0.5×, 0.75×) slow animations; higher values (2×, 3×, 4×) speed
|
||||
them up.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Slider (desktop / larger screens) */}
|
||||
<div className="hidden sm:block">
|
||||
<Slider
|
||||
value={[animationSpeedMultiplier]}
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.25}
|
||||
ticks={[0.25, 0.5, 0.75, 1, 2, 3, 4]}
|
||||
formatTick={(t) => (t === 1 ? "1x" : `${t}x`)}
|
||||
onValueChange={(v: number[]) =>
|
||||
setAnimationSpeedMultiplier(v[0] ?? 1)
|
||||
}
|
||||
aria-label="Animation speed multiplier"
|
||||
className="mt-1"
|
||||
disabled={prefersReducedMotion}
|
||||
/>
|
||||
</div>
|
||||
{/* Dropdown fallback (small screens) */}
|
||||
<div className="block sm:hidden">
|
||||
<select
|
||||
className="bg-background border-border text-foreground w-full rounded-md border px-2 py-2 text-sm disabled:opacity-60"
|
||||
value={animationSpeedMultiplier}
|
||||
disabled={prefersReducedMotion}
|
||||
onChange={(e) =>
|
||||
setAnimationSpeedMultiplier(
|
||||
parseFloat(e.target.value) || 1,
|
||||
)
|
||||
}
|
||||
aria-label="Animation speed multiplier select"
|
||||
>
|
||||
{[0.25, 0.5, 0.75, 1, 2, 3, 4].map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v === 1 ? "1x (default)" : `${v}x`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={animationPrefsUpdating}
|
||||
variant="default"
|
||||
>
|
||||
{animationPrefsUpdating ? "Saving..." : "Save Preferences"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="data" className="space-y-8">
|
||||
{/* Data Overview */}
|
||||
<Card className="form-section bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<Database className="text-primary h-5 w-5" />
|
||||
Account Data
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Overview of your stored information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{dataStatItems.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className="bg-card rounded-lg border p-4 transition-shadow hover:shadow-sm"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 ${item.bgColor}`}>
|
||||
<Icon className={`h-4 w-4 ${item.color}`} />
|
||||
</div>
|
||||
<span className="font-medium break-words">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-foreground text-2xl font-bold">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Data Management */}
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
Data Management
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -511,12 +698,12 @@ export function SettingsContent() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:gap-4">
|
||||
<Button
|
||||
onClick={handleExportData}
|
||||
disabled={exportDataQuery.isFetching}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
className="w-full sm:flex-1"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{exportDataQuery.isFetching ? "Exporting..." : "Export Backup"}
|
||||
@@ -527,7 +714,7 @@ export function SettingsContent() {
|
||||
onOpenChange={setIsImportDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="flex-1">
|
||||
<Button variant="outline" className="w-full sm:flex-1">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import Backup
|
||||
</Button>
|
||||
@@ -536,12 +723,62 @@ export function SettingsContent() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Backup Data</DialogTitle>
|
||||
<DialogDescription>
|
||||
Paste the contents of your backup JSON file below. This
|
||||
will add the data to your existing account.
|
||||
Upload your backup JSON file or paste the contents below.
|
||||
This will add the data to your existing account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* Import Method Selector */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={
|
||||
importMethod === "file" ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => setImportMethod("file")}
|
||||
className="flex-1"
|
||||
>
|
||||
<FileUp className="mr-2 h-4 w-4" />
|
||||
Upload File
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={
|
||||
importMethod === "paste" ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => setImportMethod("paste")}
|
||||
className="flex-1"
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Paste Content
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* File Upload Method */}
|
||||
{importMethod === "file" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-file">Select Backup File</Label>
|
||||
<Input
|
||||
id="backup-file"
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileUpload}
|
||||
disabled={importDataMutation.isPending}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select the JSON backup file you previously exported.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Paste Method */}
|
||||
{importMethod === "paste" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-content">Backup Content</Label>
|
||||
<Textarea
|
||||
id="backup-content"
|
||||
placeholder="Paste your backup JSON data here..."
|
||||
value={importData}
|
||||
onChange={(e) => setImportData(e.target.value)}
|
||||
@@ -549,127 +786,127 @@ export function SettingsContent() {
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsImportDialogOpen(false)}
|
||||
onClick={() => {
|
||||
setIsImportDialogOpen(false);
|
||||
setImportData("");
|
||||
setImportMethod("file");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{importMethod === "paste" && (
|
||||
<Button
|
||||
onClick={handleImportData}
|
||||
disabled={
|
||||
!importData.trim() || importDataMutation.isPending
|
||||
}
|
||||
className="btn-brand-primary"
|
||||
variant="default"
|
||||
>
|
||||
{importDataMutation.isPending
|
||||
? "Importing..."
|
||||
: "Import Data"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Backup Information */}
|
||||
<div className="border-border bg-muted/20 rounded-lg border p-4">
|
||||
<h4 className="font-medium">Backup Information</h4>
|
||||
<ul className="text-muted-foreground mt-2 space-y-1 text-sm">
|
||||
<li>• Regular backups protect your important business data</li>
|
||||
<li>• Backup files contain all data in secure JSON format</li>
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-between p-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="font-medium">Backup Information</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3">
|
||||
<div className="border-border bg-muted/20 border p-4">
|
||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||
<li>
|
||||
• Regular backups protect your important business data
|
||||
</li>
|
||||
<li>
|
||||
• Backup files contain all data in secure JSON format
|
||||
</li>
|
||||
<li>
|
||||
• Import adds to existing data without replacing anything
|
||||
</li>
|
||||
<li>• Store backup files in a secure, accessible location</li>
|
||||
<li>
|
||||
• Upload JSON files directly or paste content manually
|
||||
</li>
|
||||
<li>
|
||||
• Store backup files in a secure, accessible location
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="card-primary border-l-4 border-l-red-500">
|
||||
{/* Delete Account (Danger Zone) */}
|
||||
<Card className="border-destructive/50 bg-destructive/5 border">
|
||||
<CardHeader>
|
||||
<CardTitle className="card-title-warning">
|
||||
<AlertTriangle className="text-icon-red h-5 w-5" />
|
||||
<CardTitle className="text-destructive flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Danger Zone
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Irreversible actions that permanently affect your account
|
||||
<CardDescription className="text-destructive/80">
|
||||
Irreversible actions for your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||||
<h4 className="font-medium text-red-600 dark:text-red-400">
|
||||
Delete All Account Data
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
This will permanently delete all your clients, businesses,
|
||||
invoices, and related data. This action cannot be undone and
|
||||
your data cannot be recovered.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full">
|
||||
<AlertTriangle className="mr-2 h-4 w-4" />
|
||||
<Button variant="destructive" className="w-full sm:w-auto">
|
||||
Delete All Data
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-4">
|
||||
<div>
|
||||
This action cannot be undone. This will permanently delete
|
||||
all your:
|
||||
</div>
|
||||
<ul className="border-border bg-muted/50 list-inside list-disc space-y-1 rounded-lg border p-3 text-sm">
|
||||
<li>Client information and contact details</li>
|
||||
<li>Business profiles and settings</li>
|
||||
<li>Invoices and invoice line items</li>
|
||||
<li>All related data and records</li>
|
||||
</ul>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
Type{" "}
|
||||
<span className="bg-muted rounded px-2 py-1 font-mono text-sm">
|
||||
delete all my data
|
||||
</span>{" "}
|
||||
to confirm:
|
||||
</div>
|
||||
<Input
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="Type delete all my data"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete your
|
||||
account and remove your data from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="my-4 space-y-2">
|
||||
<Label htmlFor="confirm-delete">
|
||||
Type <span className="font-bold">delete all my data</span> to
|
||||
confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-delete"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="delete all my data"
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteAllData}
|
||||
disabled={
|
||||
deleteConfirmText !== "delete all my data" ||
|
||||
deleteDataMutation.isPending
|
||||
}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={deleteConfirmText !== "delete all my data"}
|
||||
>
|
||||
{deleteDataMutation.isPending
|
||||
? "Deleting..."
|
||||
: "Delete Forever"}
|
||||
Delete Account
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,21 +3,26 @@ import { HydrateClient } from "~/trpc/server";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
import { SettingsContent } from "./_components/settings-content";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="page-enter space-y-6">
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="Manage your account preferences and data"
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
||||
<SettingsContent />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type RouterOutputs } from "~/trpc/react";
|
||||
|
||||
// Dashboard stats type from the dashboard router
|
||||
export type DashboardStats = RouterOutputs["dashboard"]["getStats"];
|
||||
|
||||
// Individual invoice type from the invoices router
|
||||
export type Invoice = RouterOutputs["invoices"]["getAll"][number];
|
||||
|
||||
// Recent invoice type (includes client relation)
|
||||
export type RecentInvoice = DashboardStats["recentInvoices"][number];
|
||||
|
||||
// Revenue chart data point
|
||||
export type RevenueChartDataPoint = DashboardStats["revenueChartData"][number];
|
||||
@@ -1,202 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { DataTable } from "~/components/data/data-table";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTableColumnHeader } from "~/components/data/data-table";
|
||||
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
|
||||
import Link from "next/link";
|
||||
|
||||
// Sample data type
|
||||
interface DemoItem {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Generate sample data
|
||||
const sampleData: DemoItem[] = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `item-${i + 1}`,
|
||||
name: `Item ${i + 1}`,
|
||||
email: `item${i + 1}@example.com`,
|
||||
phone: `555-${String(Math.floor(Math.random() * 9000) + 1000)}`,
|
||||
status: ["active", "pending", "inactive"][
|
||||
Math.floor(Math.random() * 3)
|
||||
] as string,
|
||||
createdAt: new Date(Date.now() - Math.random() * 10000000000),
|
||||
}));
|
||||
|
||||
// Define columns with responsive behavior
|
||||
const columns: ColumnDef<DemoItem>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.name}</p>
|
||||
<p className="text-muted-foreground text-xs">{row.original.email}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "phone",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Phone" />
|
||||
),
|
||||
cell: ({ row }) => row.original.phone,
|
||||
meta: {
|
||||
headerClassName: "hidden md:table-cell",
|
||||
cellClassName: "hidden md:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
|
||||
row.original.status === "active"
|
||||
? "bg-green-100 text-green-700"
|
||||
: row.original.status === "pending"
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{row.original.status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt") as Date;
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden lg:table-cell",
|
||||
cellClassName: "hidden lg:table-cell",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default function TableLayoutDemo() {
|
||||
const [data] = useState(sampleData);
|
||||
|
||||
const filterableColumns = [
|
||||
{
|
||||
id: "status",
|
||||
title: "Status",
|
||||
options: [
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Pending", value: "pending" },
|
||||
{ label: "Inactive", value: "inactive" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10">
|
||||
<DashboardBreadcrumbs />
|
||||
|
||||
<PageHeader
|
||||
title="Table Layout & Breadcrumb Demo"
|
||||
description="This demo showcases the improved responsive layouts and dynamic breadcrumbs. The breadcrumbs automatically handle pluralization and capitalization. Navigate to different pages to see how they adapt."
|
||||
variant="gradient"
|
||||
>
|
||||
<Button variant="brand" size="lg">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
Add Item
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="mt-8">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
searchPlaceholder="Search items..."
|
||||
filterableColumns={filterableColumns}
|
||||
showColumnVisibility={true}
|
||||
showPagination={true}
|
||||
showSearch={true}
|
||||
pageSize={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 space-y-4">
|
||||
<h2 className="text-2xl font-bold">Layout Improvements</h2>
|
||||
<div className="text-muted-foreground space-y-2 text-sm">
|
||||
<p>
|
||||
• Page header: Description text wraps below the title and action
|
||||
buttons
|
||||
</p>
|
||||
<p>
|
||||
• Filter bar: Search and filters stay inline on mobile with proper
|
||||
wrapping
|
||||
</p>
|
||||
<p>
|
||||
• Pagination bar: Entry count and controls remain on the same line
|
||||
on mobile
|
||||
</p>
|
||||
<p>
|
||||
• Columns: Responsive hiding with both headers and cells hidden
|
||||
together
|
||||
</p>
|
||||
<p>
|
||||
• Compact design: Tighter padding for more efficient space usage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-8 text-2xl font-bold">Dynamic Breadcrumbs</h2>
|
||||
<div className="text-muted-foreground space-y-2 text-sm">
|
||||
<p>
|
||||
• Automatic pluralization: "Business" becomes "Businesses" on list
|
||||
pages
|
||||
</p>
|
||||
<p>• Smart capitalization: Route segments are properly capitalized</p>
|
||||
<p>• Context awareness: Shows resource names instead of UUIDs</p>
|
||||
<p>
|
||||
• Clean presentation: Edit pages show the resource name, not "Edit"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm font-medium">Try these example routes:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard/businesses">
|
||||
<Button variant="outline" size="sm">
|
||||
Businesses List
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/dashboard/clients">
|
||||
<Button variant="outline" size="sm">
|
||||
Clients List
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/dashboard/invoices">
|
||||
<Button variant="outline" size="sm">
|
||||
Invoices List
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Navbar } from "~/components/layout/navbar";
|
||||
import { Sidebar } from "~/components/layout/sidebar";
|
||||
|
||||
export default function InvoicesLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<main className="bg-background min-h-screen flex-1">{children}</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +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 { InvoiceList } from "~/components/data/invoice-list";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
export default async function InvoicesPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<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 view invoices
|
||||
</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button size="lg">Sign In</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prefetch invoices data
|
||||
void api.invoices.getAll.prefetch();
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
<div className="p-6">
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-2 text-3xl font-bold">Invoices</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your invoices and payments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InvoiceList />
|
||||
</div>
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
+36
-9
@@ -1,10 +1,14 @@
|
||||
import "~/styles/globals.css";
|
||||
|
||||
import { type Metadata } from "next";
|
||||
import { Geist, Azeret_Mono } from "next/font/google";
|
||||
import { Inter, Playfair_Display, Geist_Mono } from "next/font/google";
|
||||
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
import { Toaster } from "~/components/ui/toaster";
|
||||
import { Toaster } from "~/components/ui/sonner";
|
||||
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
|
||||
|
||||
|
||||
import { UmamiScript } from "~/components/analytics/umami-script";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "beenvoice - Invoicing Made Simple",
|
||||
@@ -13,14 +17,21 @@ export const metadata: Metadata = {
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
};
|
||||
|
||||
const geist = Geist({
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-sans",
|
||||
variable: "--font-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const azeretMono = Azeret_Mono({
|
||||
const playfair = Playfair_Display({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-azeret-mono",
|
||||
variable: "--font-heading",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-mono",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
@@ -28,10 +39,26 @@ export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="en" className={`${geist.variable} ${azeretMono.variable}`}>
|
||||
<body className="relative min-h-screen overflow-x-hidden font-sans antialiased">
|
||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||
<html
|
||||
suppressHydrationWarning
|
||||
lang="en"
|
||||
className={`${inter.variable} ${playfair.variable} ${geistMono.variable}`}
|
||||
>
|
||||
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
|
||||
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
|
||||
<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="w-[800px] h-[800px] bg-neutral-400/40 dark:bg-neutral-500/30 rounded-full blur-3xl animate-blob"></div>
|
||||
</div>
|
||||
|
||||
<TRPCReactProvider>
|
||||
<AnimationPreferencesProvider>
|
||||
<div className="relative z-10">
|
||||
{children}
|
||||
</div>
|
||||
</AnimationPreferencesProvider>
|
||||
<Toaster />
|
||||
<UmamiScript />
|
||||
</TRPCReactProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+129
-341
@@ -9,48 +9,53 @@ import {
|
||||
Check,
|
||||
Zap,
|
||||
Shield,
|
||||
Globe,
|
||||
Sparkles,
|
||||
BarChart3,
|
||||
Clock,
|
||||
Rocket,
|
||||
Heart,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="bg-page-gradient min-h-screen">
|
||||
<div className="min-h-screen relative overflow-x-hidden">
|
||||
<AuthRedirect />
|
||||
|
||||
{/* Blob Background for Homepage */}
|
||||
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
|
||||
<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="w-[800px] h-[800px] bg-neutral-400/30 dark:bg-neutral-500/20 rounded-full blur-3xl animate-blob"></div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="nav-sticky">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex h-14 items-center justify-between sm:h-16">
|
||||
<nav className="fixed top-4 left-4 right-4 z-50 m-4 rounded-2xl border border-border/60 bg-background/80 backdrop-blur-md">
|
||||
<div className="mx-auto px-6">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<Logo />
|
||||
<div className="hidden items-center space-x-6 md:flex">
|
||||
<a href="#features" className="nav-link">
|
||||
<div className="hidden items-center space-x-8 md:flex">
|
||||
<a
|
||||
href="#features"
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
|
||||
>
|
||||
Features
|
||||
</a>
|
||||
<a href="#pricing" className="nav-link">
|
||||
<a
|
||||
href="#pricing"
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
|
||||
>
|
||||
Pricing
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/auth/signin">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-700 hover:text-slate-900 dark:text-slate-200 dark:hover:text-white"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<span className="hidden sm:inline">Sign In</span>
|
||||
<span className="sm:hidden">Sign In</span>
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/auth/register">
|
||||
<Button size="sm" className="btn-brand-primary">
|
||||
<span className="hidden sm:inline">Get Started</span>
|
||||
<span className="sm:hidden">Start</span>
|
||||
<Button size="sm" variant="default" className="rounded-xl px-6">
|
||||
Get Started
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -59,396 +64,179 @@ export default function HomePage() {
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="bg-hero-gradient relative overflow-hidden px-4 pt-12 pb-16 sm:pt-20">
|
||||
{/* Background decoration */}
|
||||
<div className="hero-overlay"></div>
|
||||
<div className="hero-orb-1"></div>
|
||||
<div className="hero-orb-2"></div>
|
||||
<div className="hero-orb-3"></div>
|
||||
<div className="relative container mx-auto text-center">
|
||||
<section className="relative pt-48 pb-32">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Badge className="badge-brand mb-4 sm:mb-6">
|
||||
<Sparkles className="mr-1 h-3 w-3" />
|
||||
Free Forever
|
||||
<Badge className="bg-primary/10 text-primary border-primary/20 mb-8 border px-4 py-1 text-sm rounded-full">
|
||||
<Zap className="mr-2 h-3.5 w-3.5" />
|
||||
Completely Free for Everyone
|
||||
</Badge>
|
||||
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight text-white sm:mb-6 sm:text-6xl lg:text-7xl">
|
||||
Simple Invoicing for
|
||||
<span className="block text-emerald-50">Freelancers</span>
|
||||
<h1 className="text-foreground mb-8 text-6xl font-heading font-bold tracking-tight sm:text-7xl lg:text-8xl leading-tight">
|
||||
Invoicing Made <br />
|
||||
<span className="text-primary italic">Beautifully Simple.</span>
|
||||
</h1>
|
||||
|
||||
<p className="mx-auto mb-6 max-w-2xl text-lg leading-relaxed text-emerald-50/90 sm:mb-8 sm:text-xl">
|
||||
Create professional invoices, manage clients, and track payments.
|
||||
Built for freelancers and small businesses—
|
||||
<span className="font-semibold text-white">completely free</span>.
|
||||
<p className="text-muted-foreground mx-auto mb-12 max-w-2xl text-xl leading-relaxed font-sans">
|
||||
Create professional invoices, manage clients, and track payments with a tool that feels as good as it looks.
|
||||
</p>
|
||||
|
||||
<div className="btn-group">
|
||||
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
|
||||
<Link href="/auth/register">
|
||||
<Button
|
||||
size="lg"
|
||||
className="btn-brand-secondary group w-full px-6 py-3 text-base font-semibold shadow-xl transition-all duration-300 sm:w-auto sm:px-8 sm:py-4 sm:text-lg"
|
||||
className="h-14 px-10 text-lg rounded-2xl shadow-xl shadow-primary/20 hover:shadow-2xl hover:shadow-primary/30 transition-all duration-300"
|
||||
>
|
||||
Get Started
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1 sm:h-5 sm:w-5" />
|
||||
Start For Free
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="#features">
|
||||
<a href="#features">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="group w-full border-white/30 px-6 py-3 text-base text-white hover:bg-white/10 sm:w-auto sm:px-8 sm:py-4 sm:text-lg"
|
||||
className="h-14 px-10 text-lg rounded-2xl border-border/50 bg-background/50 hover:bg-background/80 backdrop-blur-sm"
|
||||
>
|
||||
Learn More
|
||||
<ChevronRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1 sm:h-5 sm:w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col items-center justify-center gap-2 text-sm text-emerald-50/80 sm:mt-12 sm:flex-row sm:gap-6">
|
||||
{[
|
||||
"No credit card required",
|
||||
"Setup in 2 minutes",
|
||||
"Free forever",
|
||||
].map((text, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-100" />
|
||||
<span className="text-center">{text}</span>
|
||||
<div className="mt-16 text-muted-foreground/80 flex flex-col items-center justify-center gap-2 text-sm sm:flex-row sm:gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="text-primary h-4 w-4" />
|
||||
<span>No credit card required</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="text-primary h-4 w-4" />
|
||||
<span>Setup in 2 minutes</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="text-primary h-4 w-4" />
|
||||
<span>Free forever</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section
|
||||
id="features"
|
||||
className="bg-features-gradient relative overflow-hidden py-16 sm:py-24"
|
||||
>
|
||||
{/* Floating background elements */}
|
||||
<div className="floating-decoration-1"></div>
|
||||
<div className="floating-decoration-2"></div>
|
||||
<div className="relative container mx-auto px-4">
|
||||
<div className="mb-12 text-center sm:mb-16">
|
||||
<Badge className="badge-features mb-4">
|
||||
<Zap className="mr-1 h-3 w-3" />
|
||||
Features
|
||||
</Badge>
|
||||
<h2 className="mb-4 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl lg:text-5xl dark:text-slate-100">
|
||||
Everything you need to
|
||||
<span className="text-brand-gradient block">get paid</span>
|
||||
<section id="features" className="py-24 relative">
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<div className="mb-20 text-center">
|
||||
<h2 className="text-foreground mb-6 text-4xl font-heading font-bold sm:text-5xl">
|
||||
Everything you need to <span className="italic text-primary">thrive</span>
|
||||
</h2>
|
||||
<p className="mx-auto max-w-2xl text-lg text-slate-600 sm:text-xl dark:text-slate-300">
|
||||
Simple, powerful features for freelancers and small businesses.
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg">
|
||||
Powerful features wrapped in a calm, focused interface.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Feature 1 */}
|
||||
<Card className="card-floating group">
|
||||
<CardContent className="p-6 sm:p-8">
|
||||
<div className="icon-bg-brand mb-4">
|
||||
<Rocket className="h-6 w-6" />
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
icon: Rocket,
|
||||
title: "Quick Setup",
|
||||
description: "Start creating invoices immediately. No complicated setup required.",
|
||||
items: ["Simple client management", "Professional templates", "Easy invoice sending"]
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: "Payment Tracking",
|
||||
description: "Keep track of invoice status and monitor your payments effortlessly.",
|
||||
items: ["Invoice status tracking", "Payment history", "Overdue notifications"]
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Professional Features",
|
||||
description: "Tools that make you look professional and get you paid faster.",
|
||||
items: ["PDF generation", "Custom tax rates", "Professional numbering"]
|
||||
}
|
||||
].map((feature, i) => (
|
||||
<Card key={i} className="group hover:-translate-y-2 transition-transform duration-500 border-border/40 bg-background/60 backdrop-blur-xl">
|
||||
<CardContent className="p-8">
|
||||
<div className="bg-primary/10 text-primary mb-6 inline-flex rounded-2xl p-4">
|
||||
<feature.icon className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
Quick Setup
|
||||
<h3 className="text-foreground mb-4 text-2xl font-bold font-heading">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="mb-4 text-slate-600 dark:text-slate-300">
|
||||
Start creating invoices immediately. No complicated setup
|
||||
required.
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
<ul className="feature-list">
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Simple client management
|
||||
</li>
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Professional templates
|
||||
</li>
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Easy invoice sending
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Feature 2 */}
|
||||
<Card className="card-floating group">
|
||||
<CardContent className="p-6 sm:p-8">
|
||||
<div className="icon-bg-blue mb-4">
|
||||
<BarChart3 className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
Payment Tracking
|
||||
</h3>
|
||||
<p className="mb-4 text-slate-600 dark:text-slate-300">
|
||||
Keep track of invoice status and monitor payments.
|
||||
</p>
|
||||
<ul className="feature-list">
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Invoice status tracking
|
||||
</li>
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Payment history
|
||||
</li>
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Overdue notifications
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Feature 3 */}
|
||||
<Card className="card-floating group">
|
||||
<CardContent className="p-6 sm:p-8">
|
||||
<div className="icon-bg-purple mb-4">
|
||||
<Globe className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
Professional Features
|
||||
</h3>
|
||||
<p className="mb-4 text-slate-600 dark:text-slate-300">
|
||||
Professional features to help you get paid on time.
|
||||
</p>
|
||||
<ul className="feature-list">
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
PDF generation
|
||||
</li>
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Custom tax rates
|
||||
</li>
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Professional numbering
|
||||
<ul className="space-y-3">
|
||||
{feature.items.map((item, j) => (
|
||||
<li key={j} className="flex items-center gap-3 text-sm text-foreground/80">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<section
|
||||
id="pricing"
|
||||
className="bg-features-gradient relative overflow-hidden py-16 sm:py-24"
|
||||
>
|
||||
{/* Floating background elements */}
|
||||
<div className="floating-decoration-1"></div>
|
||||
<div className="floating-decoration-2"></div>
|
||||
<div className="relative container mx-auto px-4">
|
||||
<div className="mb-12 text-center sm:mb-16">
|
||||
<h2 className="mb-4 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl lg:text-5xl dark:text-slate-100">
|
||||
Simple pricing
|
||||
</h2>
|
||||
<p className="mx-auto max-w-2xl text-lg text-slate-600 sm:text-xl dark:text-slate-300">
|
||||
Start free, stay free. No hidden fees or limits.
|
||||
</p>
|
||||
<section id="pricing" className="py-24 relative overflow-hidden">
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<div className="max-w-4xl mx-auto text-center mb-16">
|
||||
<h2 className="text-5xl font-heading font-bold mb-6">Simple Pricing</h2>
|
||||
<p className="text-xl text-muted-foreground">Focus on your work, not on fees.</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-md">
|
||||
<Card className="relative border-2 border-emerald-500 bg-white/90 shadow-2xl backdrop-blur-sm dark:border-emerald-400 dark:bg-slate-800/90">
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
|
||||
<Badge className="badge-success px-6 py-1">Forever Free</Badge>
|
||||
</div>
|
||||
<CardContent className="p-6 text-center sm:p-8">
|
||||
<div className="mb-6">
|
||||
<div className="text-brand-gradient mb-2 text-5xl font-bold sm:text-6xl">
|
||||
$0
|
||||
</div>
|
||||
<div className="text-slate-600 dark:text-slate-400">
|
||||
per month, forever
|
||||
</div>
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card className="relative overflow-visible border-primary/50 shadow-2xl shadow-primary/5 bg-background/80 backdrop-blur-xl">
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground px-6 py-1.5 rounded-full text-sm font-medium shadow-lg">
|
||||
Forever Free
|
||||
</div>
|
||||
<CardContent className="p-10 text-center">
|
||||
<div className="mb-2 text-6xl font-bold font-heading">$0</div>
|
||||
<div className="text-muted-foreground mb-8">No credit card required.</div>
|
||||
|
||||
<div className="mb-6 space-y-3 text-left sm:mb-8 sm:space-y-4">
|
||||
<div className="space-y-4 mb-10 text-left pl-8">
|
||||
{[
|
||||
"Unlimited invoices",
|
||||
"Unlimited clients",
|
||||
"Professional templates",
|
||||
"PDF export",
|
||||
"Payment tracking",
|
||||
"Multi-business support",
|
||||
"Line item details",
|
||||
"Free forever",
|
||||
].map((feature, i) => (
|
||||
"Unlimited Invoices",
|
||||
"Unlimited Clients",
|
||||
"PDF Downloads",
|
||||
"Payment Tracking",
|
||||
"Email Support"
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 flex-shrink-0 text-emerald-500" />
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{feature}
|
||||
</span>
|
||||
<Check className="h-5 w-5 text-primary shrink-0" />
|
||||
<span className="text-foreground/90">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link href="/auth/register">
|
||||
<Button
|
||||
variant="brand"
|
||||
className="w-full py-3 text-base font-semibold sm:text-lg"
|
||||
>
|
||||
<Link href="/auth/register" className="block">
|
||||
<Button size="lg" className="w-full text-lg h-12 rounded-xl">
|
||||
Get Started
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<p className="mt-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
No credit card required
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Why Choose */}
|
||||
<section className="bg-features-gradient relative overflow-hidden py-16 sm:py-24">
|
||||
{/* Floating background elements */}
|
||||
<div className="floating-decoration-1"></div>
|
||||
<div className="floating-decoration-2"></div>
|
||||
<div className="relative container mx-auto px-4">
|
||||
<div className="mb-12 text-center sm:mb-16">
|
||||
<h2 className="mb-4 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl lg:text-5xl dark:text-slate-100">
|
||||
Why choose
|
||||
<span className="text-brand-gradient block">BeenVoice</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:gap-8 md:grid-cols-3">
|
||||
<div className="text-center">
|
||||
<div className="icon-bg-emerald mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl text-white shadow-lg">
|
||||
<Zap className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
Quick & Simple
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-300">
|
||||
No learning curve. Start creating invoices in minutes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="icon-bg-blue mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl text-white shadow-lg">
|
||||
<Shield className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
Always Free
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-300">
|
||||
No hidden fees, no premium tiers. All features are free.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="icon-bg-purple mb-4">
|
||||
<Clock className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
Save Time
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-300">
|
||||
Focus on your work, not paperwork. Automated calculations and
|
||||
formatting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="bg-hero-gradient relative overflow-hidden py-16 sm:py-24">
|
||||
<div className="hero-overlay"></div>
|
||||
<div className="hero-orb-1"></div>
|
||||
<div className="hero-orb-2"></div>
|
||||
<div className="hero-orb-3"></div>
|
||||
|
||||
<div className="relative container mx-auto px-4 text-center">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h2 className="mb-4 text-3xl font-bold text-white sm:mb-6 sm:text-4xl lg:text-5xl">
|
||||
Ready to get started?
|
||||
</h2>
|
||||
<p className="mb-6 text-lg text-emerald-50/90 sm:mb-8 sm:text-xl">
|
||||
Join thousands of freelancers already using BeenVoice. Start
|
||||
today—completely free.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
|
||||
<Link href="/auth/register">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
className="btn-brand-secondary group w-full px-6 py-3 text-base font-semibold shadow-xl transition-all duration-300 sm:w-auto sm:px-8 sm:py-4 sm:text-lg"
|
||||
>
|
||||
Start Free Today
|
||||
<Rocket className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1 sm:h-5 sm:w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col items-center justify-center gap-3 text-emerald-50/80 sm:mt-8 sm:flex-row sm:gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart className="h-4 w-4" />
|
||||
Free forever
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Secure & private
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
2-minute setup
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-features-gradient border-t py-8 sm:py-12 dark:border-slate-700">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center">
|
||||
<Logo className="mx-auto mb-4" />
|
||||
<p className="mb-4 text-sm text-slate-600 sm:mb-6 sm:text-base dark:text-slate-400">
|
||||
Simple invoicing for freelancers. Free, forever.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-600 sm:gap-6 dark:text-slate-400">
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="transition-colors hover:text-emerald-600 dark:hover:text-emerald-400"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="transition-colors hover:text-emerald-600 dark:hover:text-emerald-400"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
<a
|
||||
href="#features"
|
||||
className="transition-colors hover:text-emerald-600 dark:hover:text-emerald-400"
|
||||
>
|
||||
Features
|
||||
</a>
|
||||
<a
|
||||
href="#pricing"
|
||||
className="transition-colors hover:text-emerald-600 dark:hover:text-emerald-400"
|
||||
>
|
||||
Pricing
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 sm:mt-8 sm:pt-8 dark:border-slate-700">
|
||||
<p className="text-sm text-slate-600 sm:text-base dark:text-slate-400">
|
||||
© 2025 Sean O'Connor.
|
||||
</p>
|
||||
<footer className="border-t border-border/40 bg-background/50 backdrop-blur-sm py-12 mt-12">
|
||||
<div className="container mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Logo size="sm" />
|
||||
<span className="text-sm text-muted-foreground">© 2024 beenvoice</span>
|
||||
</div>
|
||||
<div className="flex gap-8 text-sm text-muted-foreground">
|
||||
<a href="#" className="hover:text-foreground transition-colors">Privacy</a>
|
||||
<a href="#" className="hover:text-foreground transition-colors">Terms</a>
|
||||
<a href="#" className="hover:text-foreground transition-colors">Contact</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user