mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a12060f5ff | |||
| 117ba0832a |
@@ -43,13 +43,6 @@ beenvoice is a professional invoicing application built with the T3 stack (Next.
|
|||||||
- Protected routes require authentication
|
- Protected routes require authentication
|
||||||
- Follow NextAuth.js security best practices
|
- Follow NextAuth.js security best practices
|
||||||
|
|
||||||
### Development Tools
|
|
||||||
- Use ESLint and Prettier for code formatting
|
|
||||||
- Use TypeScript for type safety
|
|
||||||
- Exclusively use bun for development and production. Do not use Node.js or Deno.
|
|
||||||
- Stay away from starting development servers or running builds unless absolutely necessary.
|
|
||||||
- Run lints and typechecks when helpful.
|
|
||||||
|
|
||||||
## Component Architecture
|
## Component Architecture
|
||||||
|
|
||||||
### UI Components (shadcn/ui)
|
### UI Components (shadcn/ui)
|
||||||
@@ -118,21 +111,21 @@ beenvoice is a professional invoicing application built with the T3 stack (Next.
|
|||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
// Required props
|
// Required props
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
// Optional props with defaults
|
// Optional props with defaults
|
||||||
variant?: "default" | "success" | "warning" | "error";
|
variant?: "default" | "success" | "warning" | "error";
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
|
|
||||||
// Styling props
|
// Styling props
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
|
||||||
// Accessibility
|
// Accessibility
|
||||||
"aria-label"?: string;
|
"aria-label"?: string;
|
||||||
}
|
}
|
||||||
@@ -244,7 +237,7 @@ export const exampleRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// Business logic here
|
// Business logic here
|
||||||
}),
|
}),
|
||||||
|
|
||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
.input(z.object({ /* pagination/filtering */ }))
|
.input(z.object({ /* pagination/filtering */ }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
@@ -432,10 +425,10 @@ export const exampleRouter = createTRPCRouter({
|
|||||||
- Document emergency procedures
|
- Document emergency procedures
|
||||||
|
|
||||||
## Remember
|
## Remember
|
||||||
This is a business application where reliability, security, and professional user experience are critical. Every decision should prioritize these values over development convenience or flashy features.
|
This is a business application where reliability, security, and professional user experience are critical. Every decision should prioritize these values over development convenience or flashy features.
|
||||||
|
|
||||||
- Don't create demo pages unless absolutely necessary.
|
- Don't create demo pages unless absolutely necessary.
|
||||||
- Don't create unnecessary complexity.
|
- Don't create unnecessary complexity.
|
||||||
- Don't run builds unless absolutely necessary, if you do, kill the dev servers.
|
- Don't run builds unless absolutely necessary, if you do, kill the dev servers.
|
||||||
- Don't start new dev servers unless asked.
|
- Don't start new dev servers unless asked.
|
||||||
- Don't start drizzle studio- you cannot do anything with it.
|
- Don't start drizzle studio- you cannot do anything with it.
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.next
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
Dockerfile*
|
|
||||||
docker-compose*
|
|
||||||
README.md
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
.env*
|
|
||||||
!.env.example
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
coverage
|
|
||||||
*.tsbuildinfo
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
+18
-46
@@ -1,51 +1,23 @@
|
|||||||
# Copy this file to .env before running Docker Compose:
|
# Since the ".env" file is gitignored, you can use the ".env.example" file to
|
||||||
# cp .env.example .env
|
# build a new ".env" file when you clone the repo. Keep this file up-to-date
|
||||||
|
# when you add new variables to `.env`.
|
||||||
|
|
||||||
# Runtime
|
# This file will be committed to version control, so make sure not to have any
|
||||||
NODE_ENV=production
|
# secrets in it. If you are cloning this repo, create a copy of this file named
|
||||||
WEB_PORT=3000
|
# ".env" and populate it with your secrets.
|
||||||
|
|
||||||
# Auth
|
# When adding additional environment variables, the schema in "/src/env.js"
|
||||||
# Generate with: openssl rand -base64 32
|
# should be updated accordingly.
|
||||||
AUTH_SECRET=change-me-generate-a-real-secret
|
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
|
||||||
|
|
||||||
# Public app URL
|
# Next Auth
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
# You can generate a new secret on the command line with:
|
||||||
|
# npx auth secret
|
||||||
|
# https://next-auth.js.org/configuration/options#secret
|
||||||
|
AUTH_SECRET=""
|
||||||
|
|
||||||
# Postgres used by docker-compose.yml
|
# Next Auth Discord Provider
|
||||||
POSTGRES_USER=postgres
|
AUTH_DISCORD_ID=""
|
||||||
POSTGRES_PASSWORD=postgres
|
AUTH_DISCORD_SECRET=""
|
||||||
POSTGRES_DB=postgres
|
|
||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
|
||||||
DB_DISABLE_SSL=true
|
|
||||||
|
|
||||||
# White-label defaults used at image build time.
|
# Drizzle
|
||||||
# Admin-managed platform branding in the app can override these after setup.
|
DATABASE_URL="file:./db.sqlite"
|
||||||
NEXT_PUBLIC_BRAND_NAME="beenvoice"
|
|
||||||
NEXT_PUBLIC_BRAND_TAGLINE="Simple and efficient invoicing for freelancers and small businesses"
|
|
||||||
NEXT_PUBLIC_BRAND_LOGO_TEXT="beenvoice"
|
|
||||||
NEXT_PUBLIC_BRAND_ICON="$"
|
|
||||||
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME="beenvoice"
|
|
||||||
NEXT_PUBLIC_DEFAULT_FONT="brand"
|
|
||||||
NEXT_PUBLIC_DEFAULT_BODY_FONT="brand"
|
|
||||||
NEXT_PUBLIC_DEFAULT_HEADING_FONT="brand"
|
|
||||||
NEXT_PUBLIC_DEFAULT_RADIUS="xl"
|
|
||||||
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE="floating"
|
|
||||||
|
|
||||||
# Email delivery via Resend (optional)
|
|
||||||
# Leave blank to disable invoice/password-reset email delivery.
|
|
||||||
RESEND_API_KEY=
|
|
||||||
RESEND_DOMAIN=
|
|
||||||
|
|
||||||
# Analytics via Umami (optional)
|
|
||||||
# Leave website ID blank to disable analytics.
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
|
|
||||||
|
|
||||||
# SSO via Authentik OIDC (optional)
|
|
||||||
NEXT_PUBLIC_AUTHENTIK_ENABLED=false
|
|
||||||
AUTHENTIK_ISSUER=
|
|
||||||
AUTHENTIK_CLIENT_ID=
|
|
||||||
AUTHENTIK_CLIENT_SECRET=
|
|
||||||
AUTHENTIK_ORIGIN=
|
|
||||||
|
|||||||
+4
-3
@@ -34,12 +34,13 @@ yarn-error.log*
|
|||||||
# local env files
|
# local env files
|
||||||
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
||||||
.env
|
.env
|
||||||
.env.prod
|
|
||||||
.env*.local
|
.env*.local
|
||||||
.env*.production
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# idea files
|
# idea files
|
||||||
.idea
|
.idea
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
FROM oven/bun:1 AS base
|
|
||||||
WORKDIR /usr/src/app
|
|
||||||
|
|
||||||
FROM base AS install
|
|
||||||
COPY package.json bun.lock ./
|
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
|
|
||||||
FROM base AS build
|
|
||||||
COPY --from=install /usr/src/app/node_modules node_modules
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
|
||||||
SKIP_ENV_VALIDATION=1 \
|
|
||||||
NODE_OPTIONS=--max-old-space-size=4096 \
|
|
||||||
BETTER_AUTH_URL=http://localhost:3000 \
|
|
||||||
AUTH_SECRET=docker-build-placeholder-secret-do-not-use \
|
|
||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
|
||||||
RUN bun run build && bun build src/server/db/migrate.ts --target=bun --outfile=migrate.js
|
|
||||||
|
|
||||||
FROM base AS release
|
|
||||||
ENV NODE_ENV=production \
|
|
||||||
PORT=3000 \
|
|
||||||
HOSTNAME=0.0.0.0
|
|
||||||
|
|
||||||
COPY --from=build /usr/src/app/.next/standalone ./
|
|
||||||
COPY --from=build /usr/src/app/.next/static ./.next/static
|
|
||||||
COPY --from=build /usr/src/app/public ./public
|
|
||||||
COPY --from=build /usr/src/app/migrate.js ./migrate.js
|
|
||||||
COPY --from=build /usr/src/app/drizzle ./drizzle
|
|
||||||
|
|
||||||
RUN chmod -R a+rX drizzle migrate.js public
|
|
||||||
|
|
||||||
USER bun
|
|
||||||
EXPOSE 3000
|
|
||||||
CMD ["sh", "-c", "bun migrate.js && bun server.js"]
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||

|
|
||||||
|
|
||||||
# beenvoice - Invoicing Made Simple
|
# beenvoice - Invoicing Made Simple
|
||||||
|
|
||||||
A modern, professional invoicing application built for freelancers and small businesses. beenvoice provides a clean, efficient way to manage clients and create professional invoices with ease.
|
A modern, professional invoicing application built for freelancers and small businesses. beenvoice provides a clean, efficient way to manage clients and create professional invoices with ease.
|
||||||
@@ -8,37 +6,30 @@ A modern, professional invoicing application built for freelancers and small bus
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **🔐 Secure Authentication** - Email/password registration and sign-in with better-auth, plus SSO via Authentik OIDC
|
- **🔐 Secure Authentication** - Email/password registration and sign-in with NextAuth.js
|
||||||
- **👥 Client Management** - Create, edit, and manage client information
|
- **👥 Client Management** - Create, edit, and manage client information
|
||||||
- **🏢 Business Profiles** - Manage your business details, logo, and email settings
|
|
||||||
- **📄 Professional Invoices** - Generate detailed invoices with line items
|
- **📄 Professional Invoices** - Generate detailed invoices with line items
|
||||||
- **📅 Timesheet View** - Calendar-based time entry with month and week views
|
|
||||||
- **📧 Email Delivery** - Send invoices via email using Resend
|
|
||||||
- **📥 PDF Export** - Download invoices as professional PDFs
|
|
||||||
- **📊 CSV Import** - Bulk import invoice data from CSV files
|
|
||||||
- **💰 Flexible Pricing** - Set custom rates and calculate totals automatically
|
- **💰 Flexible Pricing** - Set custom rates and calculate totals automatically
|
||||||
- **📱 Responsive Design** - Works seamlessly on desktop, tablet, and mobile
|
- **📱 Responsive Design** - Works seamlessly on desktop, tablet, and mobile
|
||||||
- **🎨 Modern UI** - Clean, professional interface built with shadcn/ui
|
- **🎨 Modern UI** - Clean, professional interface built with shadcn/ui
|
||||||
- **⚡ Type-Safe** - Full TypeScript support with tRPC for API calls
|
- **⚡ Type-Safe** - Full TypeScript support with tRPC for API calls
|
||||||
- **💾 PostgreSQL Database** - Robust relational database with Drizzle ORM
|
- **💾 Local Database** - SQLite database with Drizzle ORM
|
||||||
|
|
||||||
## 🚀 Tech Stack
|
## 🚀 Tech Stack
|
||||||
|
|
||||||
- **Frontend**: Next.js 16 with App Router
|
- **Frontend**: Next.js 15 with App Router
|
||||||
- **Backend**: tRPC for type-safe API calls
|
- **Backend**: tRPC for type-safe API calls
|
||||||
- **Database**: Drizzle ORM with PostgreSQL
|
- **Database**: Drizzle ORM with LibSQL (SQLite)
|
||||||
- **Authentication**: better-auth with email/password and Authentik OIDC SSO
|
- **Authentication**: NextAuth.js with email/password
|
||||||
- **UI Components**: shadcn/ui with Tailwind CSS v4
|
- **UI Components**: shadcn/ui with Tailwind CSS
|
||||||
- **Email**: Resend for transactional email delivery
|
- **Styling**: Geist font family
|
||||||
- **PDF**: @react-pdf/renderer for invoice PDF generation
|
- **Package Manager**: Bun (with npm fallback)
|
||||||
- **Package Manager**: Bun
|
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 18+ or Bun
|
- Node.js 18+ or Bun
|
||||||
- Docker & Docker Compose (for local PostgreSQL)
|
|
||||||
- Git
|
- Git
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
@@ -51,49 +42,36 @@ A modern, professional invoicing application built for freelancers and small bus
|
|||||||
|
|
||||||
2. **Install dependencies**
|
2. **Install dependencies**
|
||||||
```bash
|
```bash
|
||||||
|
# Using Bun (recommended)
|
||||||
bun install
|
bun install
|
||||||
|
|
||||||
|
# Or using npm
|
||||||
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Set up environment variables**
|
3. **Set up environment variables**
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `.env.local` and add your configuration:
|
Edit `.env.local` and add your configuration:
|
||||||
```env
|
```env
|
||||||
# Database
|
DATABASE_URL="file:./db.sqlite"
|
||||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice"
|
NEXTAUTH_SECRET="your-secret-key-here"
|
||||||
DB_DISABLE_SSL="true"
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
# 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. **Start the database**
|
4. **Initialize the database**
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Push the database schema**
|
|
||||||
```bash
|
```bash
|
||||||
bun run db:push
|
bun run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **Start the development server**
|
5. **Start the development server**
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
7. **Open your browser**
|
6. **Open your browser**
|
||||||
Navigate to [http://localhost:3000](http://localhost:3000)
|
Navigate to [http://localhost:3000](http://localhost:3000)
|
||||||
|
|
||||||
## 🏗️ Project Structure
|
## 🏗️ Project Structure
|
||||||
@@ -102,28 +80,21 @@ A modern, professional invoicing application built for freelancers and small bus
|
|||||||
beenvoice/
|
beenvoice/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/ # Next.js App Router pages
|
│ ├── app/ # Next.js App Router pages
|
||||||
│ │ ├── api/ # API routes (better-auth, tRPC)
|
│ │ ├── api/ # API routes (NextAuth, tRPC)
|
||||||
│ │ ├── auth/ # Authentication pages
|
│ │ ├── auth/ # Authentication pages
|
||||||
│ │ ├── dashboard/ # Main app pages
|
│ │ ├── clients/ # Client management pages
|
||||||
│ │ │ ├── clients/ # Client management pages
|
│ │ ├── invoices/ # Invoice management pages
|
||||||
│ │ │ ├── invoices/ # Invoice management pages
|
|
||||||
│ │ │ └── businesses/ # Business profile pages
|
|
||||||
│ │ └── _components/ # Page-specific components
|
│ │ └── _components/ # Page-specific components
|
||||||
│ ├── components/ # Shared UI components
|
│ ├── components/ # Shared UI components
|
||||||
│ │ ├── ui/ # shadcn/ui components
|
|
||||||
│ │ ├── data/ # Data display components
|
|
||||||
│ │ ├── forms/ # Form components
|
|
||||||
│ │ └── layout/ # Layout components
|
|
||||||
│ ├── server/ # Server-side code
|
│ ├── server/ # Server-side code
|
||||||
│ │ ├── api/ # tRPC routers
|
│ │ ├── api/ # tRPC routers
|
||||||
|
│ │ ├── auth/ # NextAuth configuration
|
||||||
│ │ └── db/ # Database schema and connection
|
│ │ └── db/ # Database schema and connection
|
||||||
│ ├── lib/ # Utilities (auth, pdf export, etc.)
|
|
||||||
│ ├── styles/ # Global styles
|
│ ├── styles/ # Global styles
|
||||||
│ └── trpc/ # tRPC client configuration
|
│ └── trpc/ # tRPC client configuration
|
||||||
├── drizzle/ # Database migrations
|
├── drizzle/ # Database migrations
|
||||||
├── public/ # Static assets
|
├── public/ # Static assets
|
||||||
├── docs/ # Documentation
|
└── docs/ # Documentation
|
||||||
└── docker-compose.yml # Local PostgreSQL setup
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎯 Usage
|
## 🎯 Usage
|
||||||
@@ -133,53 +104,41 @@ beenvoice/
|
|||||||
1. **Register an Account**
|
1. **Register an Account**
|
||||||
- Visit the sign-up page
|
- Visit the sign-up page
|
||||||
- Enter your name, email, and password
|
- Enter your name, email, and password
|
||||||
|
- Verify your email (if configured)
|
||||||
|
|
||||||
2. **Set Up Your Business**
|
2. **Add Your First Client**
|
||||||
- Navigate to Business Settings
|
|
||||||
- Add your business name, contact info, and logo
|
|
||||||
- Configure email settings for invoice delivery (Resend API key + domain)
|
|
||||||
|
|
||||||
3. **Add Your First Client**
|
|
||||||
- Navigate to the Clients page
|
- Navigate to the Clients page
|
||||||
- Click "Add New Client"
|
- Click "Add New Client"
|
||||||
- Fill in client details (name, email, phone, address)
|
- Fill in client details (name, email, phone, address)
|
||||||
|
|
||||||
4. **Create an Invoice**
|
3. **Create an Invoice**
|
||||||
- Go to the Invoices page
|
- Go to the Invoices page
|
||||||
- Click "Create New Invoice"
|
- Click "Create New Invoice"
|
||||||
- Select a client and optionally a business profile
|
- Select a client
|
||||||
- Add line items with descriptions, dates, hours, and rates
|
- Add line items with descriptions, dates, hours, and rates
|
||||||
- Use the Timesheet tab for calendar-based time entry
|
- Save and generate your invoice
|
||||||
- Save and send or download as PDF
|
|
||||||
|
|
||||||
### Features Overview
|
### Features Overview
|
||||||
|
|
||||||
#### Client Management
|
#### Client Management
|
||||||
- Create and edit client profiles
|
- Create and edit client profiles
|
||||||
- Store contact information and addresses
|
- Store contact information and addresses
|
||||||
- Set default hourly rates per client
|
|
||||||
- Search and filter client list
|
- Search and filter client list
|
||||||
|
- View client history
|
||||||
|
|
||||||
#### Invoice Creation
|
#### Invoice Creation
|
||||||
- Select from existing clients and business profiles
|
- Select from existing clients
|
||||||
- Add multiple line items with drag-and-drop reordering
|
- Add multiple line items
|
||||||
- Set custom rates per item
|
- Set custom rates per item
|
||||||
- Automatic total calculations with configurable tax rate
|
- Automatic total calculations
|
||||||
- Timesheet calendar view for date-based time tracking
|
|
||||||
- Professional invoice formatting
|
- Professional invoice formatting
|
||||||
|
|
||||||
#### Invoice Delivery
|
|
||||||
- Send invoices via email directly from the app
|
|
||||||
- Rich text email composer with preview
|
|
||||||
- Resend and re-deliver sent invoices
|
|
||||||
- Track invoice status: Draft → Sent → Paid (+ Overdue)
|
|
||||||
|
|
||||||
#### User Interface
|
#### User Interface
|
||||||
- Clean, modern design
|
- Clean, modern design
|
||||||
- Fully responsive — desktop, tablet, and mobile
|
- Responsive layout
|
||||||
- Intuitive navigation with breadcrumbs
|
- Intuitive navigation
|
||||||
- Toast notifications for feedback
|
- Toast notifications for feedback
|
||||||
- Dark mode support
|
- Modal dialogs for forms
|
||||||
|
|
||||||
## 🔧 Development
|
## 🔧 Development
|
||||||
|
|
||||||
@@ -187,53 +146,44 @@ beenvoice/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
# Development
|
||||||
bun run dev # Start development server (Turbo)
|
bun run dev # Start development server
|
||||||
bun run build # Build for production
|
bun run build # Build for production
|
||||||
bun run start # Start production server
|
bun run start # Start production server
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
bun run db:push # Push schema changes to database
|
bun run db:push # Push schema changes to database
|
||||||
bun run db:migrate # Run migrations
|
|
||||||
bun run db:studio # Open Drizzle Studio
|
bun run db:studio # Open Drizzle Studio
|
||||||
bun run db:generate # Generate new migration
|
bun run db:generate # Generate new migration
|
||||||
|
|
||||||
# Docker
|
|
||||||
bun run docker:up # Start local PostgreSQL via Docker
|
|
||||||
bun run docker:down # Stop Docker services
|
|
||||||
|
|
||||||
# Code Quality
|
# Code Quality
|
||||||
bun run lint # Run ESLint
|
bun run lint # Run ESLint
|
||||||
bun run lint:fix # Fix ESLint issues
|
bun run format # Format code with Prettier
|
||||||
bun run format:write # Format code with Prettier
|
bun run type-check # Run TypeScript type checking
|
||||||
bun run typecheck # Run TypeScript type checking
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Schema
|
### Database Schema
|
||||||
|
|
||||||
The application uses the following core tables:
|
The application uses four main tables:
|
||||||
|
|
||||||
- **users** - User accounts and authentication
|
- **users**: User accounts and authentication
|
||||||
- **sessions** - Active user sessions
|
- **clients**: Client information and contact details
|
||||||
- **clients** - Client information and contact details
|
- **invoices**: Invoice headers with client relationships
|
||||||
- **businesses** - Business profiles with email/logo settings
|
- **invoice_items**: Individual line items with pricing
|
||||||
- **invoices** - Invoice headers with client and business relationships
|
|
||||||
- **invoice_items** - Individual line items with pricing and position ordering
|
|
||||||
|
|
||||||
### API Development
|
### API Development
|
||||||
|
|
||||||
All API endpoints are built with tRPC for type safety:
|
All API endpoints are built with tRPC for type safety:
|
||||||
|
|
||||||
- **Authentication**: better-auth integration (email/password + OIDC)
|
- **Authentication**: NextAuth.js integration
|
||||||
- **Clients**: CRUD operations for client management
|
- **Clients**: CRUD operations for client management
|
||||||
- **Businesses**: Business profile management
|
- **Invoices**: Invoice creation and management
|
||||||
- **Invoices**: Invoice creation, management, and status tracking
|
|
||||||
- **Validation**: Zod schemas for input validation
|
- **Validation**: Zod schemas for input validation
|
||||||
|
|
||||||
## 🎨 Customization
|
## 🎨 Customization
|
||||||
|
|
||||||
### Styling
|
### Styling
|
||||||
|
|
||||||
The app uses Tailwind CSS v4 with a custom design system:
|
The app uses Tailwind CSS with a custom design system:
|
||||||
|
|
||||||
- **Primary Color**: Green (#16a34a)
|
- **Primary Color**: Green (#16a34a)
|
||||||
- **Font**: Geist for professional typography
|
- **Font**: Geist for professional typography
|
||||||
@@ -249,54 +199,31 @@ Update the logo and colors in:
|
|||||||
|
|
||||||
## 🚀 Deployment
|
## 🚀 Deployment
|
||||||
|
|
||||||
You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.).
|
### Vercel (Recommended)
|
||||||
|
|
||||||
1. **Build the application:**
|
1. Push your code to GitHub
|
||||||
```bash
|
2. Connect your repository to Vercel
|
||||||
bun run build
|
3. Set environment variables in Vercel dashboard
|
||||||
```
|
4. Deploy automatically on push
|
||||||
|
|
||||||
2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production)
|
### Other Platforms
|
||||||
|
|
||||||
3. **Run database migrations:**
|
The app can be deployed to any platform that supports Next.js:
|
||||||
```bash
|
|
||||||
bun run db:push
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Start the server:**
|
- **Netlify**: Use the Next.js build command
|
||||||
```bash
|
- **Railway**: Connect your GitHub repository
|
||||||
bun start
|
- **DigitalOcean App Platform**: Deploy with automatic scaling
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
Required for production:
|
Required for production:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL="postgresql://user:password@host:5432/dbname"
|
DATABASE_URL="your-database-url"
|
||||||
AUTH_SECRET="your-long-random-secret"
|
NEXTAUTH_SECRET="your-secret-key"
|
||||||
BETTER_AUTH_URL="https://your-domain.com"
|
NEXTAUTH_URL="https://your-domain.com"
|
||||||
NEXT_PUBLIC_APP_URL="https://your-domain.com"
|
|
||||||
NODE_ENV="production"
|
|
||||||
|
|
||||||
# Email (required for invoice sending)
|
|
||||||
RESEND_API_KEY="re_xxxxxxxxxxxx"
|
|
||||||
RESEND_DOMAIN="yourdomain.com"
|
|
||||||
|
|
||||||
# Optional: Authentik SSO
|
|
||||||
AUTHENTIK_ISSUER="https://your-authentik-instance/application/o/beenvoice/"
|
|
||||||
AUTHENTIK_CLIENT_ID="your-client-id"
|
|
||||||
AUTHENTIK_CLIENT_SECRET="your-client-secret"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Other Platforms
|
|
||||||
|
|
||||||
The app can be deployed to any platform that supports Next.js:
|
|
||||||
|
|
||||||
- **Coolify**: Deploy with Docker Compose support
|
|
||||||
- **Railway**: Connect your GitHub repository (includes managed PostgreSQL)
|
|
||||||
- **DigitalOcean App Platform**: Deploy with automatic scaling
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
@@ -310,7 +237,8 @@ The app can be deployed to any platform that supports Next.js:
|
|||||||
- Follow TypeScript best practices
|
- Follow TypeScript best practices
|
||||||
- Use shadcn/ui components for consistency
|
- Use shadcn/ui components for consistency
|
||||||
- Implement proper error handling
|
- Implement proper error handling
|
||||||
- Follow the existing code style (Prettier + ESLint configs provided)
|
- Add tests for new features
|
||||||
|
- Follow the existing code style
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
@@ -320,14 +248,14 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|||||||
|
|
||||||
- [T3 Stack](https://create.t3.gg/) for the excellent development stack
|
- [T3 Stack](https://create.t3.gg/) for the excellent development stack
|
||||||
- [shadcn/ui](https://ui.shadcn.com/) for beautiful UI components
|
- [shadcn/ui](https://ui.shadcn.com/) for beautiful UI components
|
||||||
- [better-auth](https://www.better-auth.com/) for modern authentication
|
- [NextAuth.js](https://next-auth.js.org/) for authentication
|
||||||
- [Drizzle ORM](https://orm.drizzle.team/) for database management
|
- [Drizzle ORM](https://orm.drizzle.team/) for database management
|
||||||
- [Resend](https://resend.com/) for reliable email delivery
|
|
||||||
|
|
||||||
## 📞 Support
|
## 📞 Support
|
||||||
|
|
||||||
- **Issues**: [GitHub Issues](https://github.com/yourusername/beenvoice/issues)
|
- **Issues**: [GitHub Issues](https://github.com/yourusername/beenvoice/issues)
|
||||||
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/beenvoice/discussions)
|
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/beenvoice/discussions)
|
||||||
|
- **Email**: support@beenvoice.com
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
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;
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
services:
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
image: beenvoice:local
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
|
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
|
|
||||||
DB_DISABLE_SSL: "true"
|
|
||||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
|
||||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
|
|
||||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
|
||||||
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
|
|
||||||
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
|
|
||||||
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
|
|
||||||
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
|
|
||||||
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
|
|
||||||
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
|
|
||||||
ports:
|
|
||||||
- "${WEB_PORT:-3000}:3000"
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
db:
|
|
||||||
image: postgres:17-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
|
||||||
volumes:
|
|
||||||
- beenvoice_pg_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
beenvoice_pg_data:
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
# 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
|
|
||||||
+11
-17
@@ -1,23 +1,17 @@
|
|||||||
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" });
|
|
||||||
|
|
||||||
// Use a relative import; path alias "~" may not resolve in CLI context
|
import { env } from "~/env";
|
||||||
// import { env } from "./src/env.js";
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
throw new Error("DATABASE_URL is not set");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
schema: "./src/server/db/schema.ts",
|
schema: "./src/server/db/schema.ts",
|
||||||
out: "./drizzle",
|
dialect: "sqlite",
|
||||||
dialect: "postgresql",
|
dbCredentials: env.DATABASE_AUTH_TOKEN
|
||||||
dbCredentials: {
|
? {
|
||||||
url: process.env.DATABASE_URL,
|
url: env.DATABASE_URL,
|
||||||
},
|
token: env.DATABASE_AUTH_TOKEN,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
url: env.DATABASE_URL,
|
||||||
|
},
|
||||||
tablesFilter: ["beenvoice_*"],
|
tablesFilter: ["beenvoice_*"],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
CREATE TABLE "beenvoice_account" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"userId" varchar(255) NOT NULL,
|
|
||||||
"accountId" varchar(255) NOT NULL,
|
|
||||||
"providerId" varchar(255) NOT NULL,
|
|
||||||
"accessToken" text,
|
|
||||||
"refreshToken" text,
|
|
||||||
"accessTokenExpiresAt" timestamp,
|
|
||||||
"refreshTokenExpiresAt" timestamp,
|
|
||||||
"scope" varchar(255),
|
|
||||||
"idToken" text,
|
|
||||||
"password" text,
|
|
||||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "beenvoice_business" (
|
|
||||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
|
||||||
"name" varchar(255) NOT NULL,
|
|
||||||
"nickname" varchar(255),
|
|
||||||
"email" varchar(255),
|
|
||||||
"phone" varchar(50),
|
|
||||||
"addressLine1" varchar(255),
|
|
||||||
"addressLine2" varchar(255),
|
|
||||||
"city" varchar(100),
|
|
||||||
"state" varchar(50),
|
|
||||||
"postalCode" varchar(20),
|
|
||||||
"country" varchar(100),
|
|
||||||
"website" varchar(255),
|
|
||||||
"taxId" varchar(100),
|
|
||||||
"logoUrl" varchar(500),
|
|
||||||
"isDefault" boolean DEFAULT false,
|
|
||||||
"resendApiKey" varchar(255),
|
|
||||||
"resendDomain" varchar(255),
|
|
||||||
"emailFromName" varchar(255),
|
|
||||||
"createdById" varchar(255) NOT NULL,
|
|
||||||
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
"updatedAt" timestamp
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "beenvoice_client" (
|
|
||||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
|
||||||
"name" varchar(255) NOT NULL,
|
|
||||||
"email" varchar(255),
|
|
||||||
"phone" varchar(50),
|
|
||||||
"addressLine1" varchar(255),
|
|
||||||
"addressLine2" varchar(255),
|
|
||||||
"city" varchar(100),
|
|
||||||
"state" varchar(50),
|
|
||||||
"postalCode" varchar(20),
|
|
||||||
"country" varchar(100),
|
|
||||||
"defaultHourlyRate" real,
|
|
||||||
"createdById" varchar(255) NOT NULL,
|
|
||||||
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
"updatedAt" timestamp
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "beenvoice_invoice_item" (
|
|
||||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
|
||||||
"invoiceId" varchar(255) NOT NULL,
|
|
||||||
"date" timestamp NOT NULL,
|
|
||||||
"description" varchar(500) NOT NULL,
|
|
||||||
"hours" real NOT NULL,
|
|
||||||
"rate" real NOT NULL,
|
|
||||||
"amount" real NOT NULL,
|
|
||||||
"position" integer DEFAULT 0 NOT NULL,
|
|
||||||
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "beenvoice_invoice" (
|
|
||||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
|
||||||
"invoiceNumber" varchar(100) NOT NULL,
|
|
||||||
"businessId" varchar(255),
|
|
||||||
"clientId" varchar(255) NOT NULL,
|
|
||||||
"issueDate" timestamp NOT NULL,
|
|
||||||
"dueDate" timestamp NOT NULL,
|
|
||||||
"status" varchar(50) DEFAULT 'draft' NOT NULL,
|
|
||||||
"totalAmount" real DEFAULT 0 NOT NULL,
|
|
||||||
"taxRate" real DEFAULT 0 NOT NULL,
|
|
||||||
"notes" varchar(1000),
|
|
||||||
"createdById" varchar(255) NOT NULL,
|
|
||||||
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
"updatedAt" timestamp
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "beenvoice_session" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"userId" varchar(255) NOT NULL,
|
|
||||||
"token" varchar(255) NOT NULL,
|
|
||||||
"expiresAt" timestamp NOT NULL,
|
|
||||||
"ipAddress" text,
|
|
||||||
"userAgent" text,
|
|
||||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "beenvoice_session_token_unique" UNIQUE("token")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "beenvoice_sso_provider" (
|
|
||||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
|
||||||
"providerId" varchar(255) NOT NULL,
|
|
||||||
"userId" varchar(255) NOT NULL,
|
|
||||||
"redirectURI" varchar(255) DEFAULT '' NOT NULL,
|
|
||||||
"oidcConfig" text,
|
|
||||||
"samlConfig" text,
|
|
||||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "beenvoice_sso_provider_providerId_unique" UNIQUE("providerId")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "beenvoice_user" (
|
|
||||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
|
||||||
"name" varchar(255) NOT NULL,
|
|
||||||
"email" varchar(255) NOT NULL,
|
|
||||||
"emailVerified" boolean DEFAULT false NOT NULL,
|
|
||||||
"image" varchar(255),
|
|
||||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"password" varchar(255),
|
|
||||||
"resetToken" varchar(255),
|
|
||||||
"resetTokenExpiry" timestamp,
|
|
||||||
"prefersReducedMotion" boolean DEFAULT false NOT NULL,
|
|
||||||
"animationSpeedMultiplier" real DEFAULT 1 NOT NULL,
|
|
||||||
"colorTheme" varchar(50) DEFAULT 'slate' NOT NULL,
|
|
||||||
"customColor" varchar(50),
|
|
||||||
"theme" varchar(20) DEFAULT 'system' NOT NULL,
|
|
||||||
CONSTRAINT "beenvoice_user_email_unique" UNIQUE("email")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "beenvoice_verification_token" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"identifier" varchar(255) NOT NULL,
|
|
||||||
"value" varchar(255) NOT NULL,
|
|
||||||
"expiresAt" timestamp NOT NULL,
|
|
||||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_account" ADD CONSTRAINT "beenvoice_account_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_business" ADD CONSTRAINT "beenvoice_business_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_client" ADD CONSTRAINT "beenvoice_client_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_businessId_beenvoice_business_id_fk" FOREIGN KEY ("businessId") REFERENCES "public"."beenvoice_business"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_session" ADD CONSTRAINT "beenvoice_session_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_sso_provider" ADD CONSTRAINT "beenvoice_sso_provider_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX "account_userId_idx" ON "beenvoice_account" USING btree ("userId");--> statement-breakpoint
|
|
||||||
CREATE INDEX "business_created_by_idx" ON "beenvoice_business" USING btree ("createdById");--> statement-breakpoint
|
|
||||||
CREATE INDEX "business_name_idx" ON "beenvoice_business" USING btree ("name");--> statement-breakpoint
|
|
||||||
CREATE INDEX "business_nickname_idx" ON "beenvoice_business" USING btree ("nickname");--> statement-breakpoint
|
|
||||||
CREATE INDEX "business_email_idx" ON "beenvoice_business" USING btree ("email");--> statement-breakpoint
|
|
||||||
CREATE INDEX "business_is_default_idx" ON "beenvoice_business" USING btree ("isDefault");--> statement-breakpoint
|
|
||||||
CREATE INDEX "client_created_by_idx" ON "beenvoice_client" USING btree ("createdById");--> statement-breakpoint
|
|
||||||
CREATE INDEX "client_name_idx" ON "beenvoice_client" USING btree ("name");--> statement-breakpoint
|
|
||||||
CREATE INDEX "client_email_idx" ON "beenvoice_client" USING btree ("email");--> statement-breakpoint
|
|
||||||
CREATE INDEX "invoice_item_invoice_id_idx" ON "beenvoice_invoice_item" USING btree ("invoiceId");--> statement-breakpoint
|
|
||||||
CREATE INDEX "invoice_item_date_idx" ON "beenvoice_invoice_item" USING btree ("date");--> statement-breakpoint
|
|
||||||
CREATE INDEX "invoice_item_position_idx" ON "beenvoice_invoice_item" USING btree ("position");--> statement-breakpoint
|
|
||||||
CREATE INDEX "invoice_business_id_idx" ON "beenvoice_invoice" USING btree ("businessId");--> statement-breakpoint
|
|
||||||
CREATE INDEX "invoice_client_id_idx" ON "beenvoice_invoice" USING btree ("clientId");--> statement-breakpoint
|
|
||||||
CREATE INDEX "invoice_created_by_idx" ON "beenvoice_invoice" USING btree ("createdById");--> statement-breakpoint
|
|
||||||
CREATE INDEX "invoice_number_idx" ON "beenvoice_invoice" USING btree ("invoiceNumber");--> statement-breakpoint
|
|
||||||
CREATE INDEX "invoice_status_idx" ON "beenvoice_invoice" USING btree ("status");--> statement-breakpoint
|
|
||||||
CREATE INDEX "session_userId_idx" ON "beenvoice_session" USING btree ("userId");--> statement-breakpoint
|
|
||||||
CREATE INDEX "sso_provider_user_id_idx" ON "beenvoice_sso_provider" USING btree ("userId");--> statement-breakpoint
|
|
||||||
CREATE INDEX "verification_token_identifier_idx" ON "beenvoice_verification_token" USING btree ("identifier");
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `beenvoice_invoice_item` ADD COLUMN `position` integer DEFAULT 0 NOT NULL;
|
||||||
|
CREATE INDEX `invoice_item_position_idx` ON `beenvoice_invoice_item` (`position`);
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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`)
|
||||||
|
);
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
CREATE TABLE "beenvoice_expense" (
|
|
||||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
|
||||||
"businessId" varchar(255),
|
|
||||||
"clientId" varchar(255),
|
|
||||||
"invoiceId" varchar(255),
|
|
||||||
"date" timestamp NOT NULL,
|
|
||||||
"description" varchar(500) NOT NULL,
|
|
||||||
"amount" real NOT NULL,
|
|
||||||
"currency" varchar(3) DEFAULT 'USD' NOT NULL,
|
|
||||||
"category" varchar(100),
|
|
||||||
"billable" boolean DEFAULT false NOT NULL,
|
|
||||||
"reimbursable" boolean DEFAULT false NOT NULL,
|
|
||||||
"notes" varchar(500),
|
|
||||||
"createdById" varchar(255) NOT NULL,
|
|
||||||
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
"updatedAt" timestamp
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "beenvoice_invoice_template" (
|
|
||||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
|
||||||
"name" varchar(255) NOT NULL,
|
|
||||||
"type" varchar(50) DEFAULT 'notes' NOT NULL,
|
|
||||||
"content" text NOT NULL,
|
|
||||||
"isDefault" boolean DEFAULT false NOT NULL,
|
|
||||||
"createdById" varchar(255) NOT NULL,
|
|
||||||
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
"updatedAt" timestamp
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_client" ADD COLUMN "currency" varchar(3) DEFAULT 'USD' NOT NULL;--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_invoice" ADD COLUMN "currency" varchar(3) DEFAULT 'USD' NOT NULL;--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_businessId_beenvoice_business_id_fk" FOREIGN KEY ("businessId") REFERENCES "public"."beenvoice_business"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_invoice_template" ADD CONSTRAINT "beenvoice_invoice_template_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX "expense_created_by_idx" ON "beenvoice_expense" USING btree ("createdById");--> statement-breakpoint
|
|
||||||
CREATE INDEX "expense_client_id_idx" ON "beenvoice_expense" USING btree ("clientId");--> statement-breakpoint
|
|
||||||
CREATE INDEX "expense_invoice_id_idx" ON "beenvoice_expense" USING btree ("invoiceId");--> statement-breakpoint
|
|
||||||
CREATE INDEX "expense_date_idx" ON "beenvoice_expense" USING btree ("date");--> statement-breakpoint
|
|
||||||
CREATE INDEX "expense_billable_idx" ON "beenvoice_expense" USING btree ("billable");--> statement-breakpoint
|
|
||||||
CREATE INDEX "invoice_template_created_by_idx" ON "beenvoice_invoice_template" USING btree ("createdById");--> statement-breakpoint
|
|
||||||
CREATE INDEX "invoice_template_type_idx" ON "beenvoice_invoice_template" USING btree ("type");
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `beenvoice_invoice` ADD COLUMN `taxRate` real NOT NULL DEFAULT 0;
|
||||||
|
UPDATE `beenvoice_invoice` SET `taxRate` = 0 WHERE `taxRate` IS NULL;
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE "beenvoice_expense" ADD COLUMN "taxDeductible" boolean DEFAULT false NOT NULL;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE "beenvoice_user" ADD COLUMN "interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL;
|
|
||||||
ALTER TABLE "beenvoice_user" ADD COLUMN "fontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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`);
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
ALTER TABLE "beenvoice_user"
|
|
||||||
ADD COLUMN "bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_user"
|
|
||||||
ADD COLUMN "headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_user"
|
|
||||||
ADD COLUMN "radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL;
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_user"
|
|
||||||
ADD COLUMN "sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL;
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
ALTER TABLE "beenvoice_user"
|
|
||||||
ADD COLUMN "role" varchar(20) DEFAULT 'user' NOT NULL;
|
|
||||||
--> statement-breakpoint
|
|
||||||
UPDATE "beenvoice_user"
|
|
||||||
SET "role" = 'admin'
|
|
||||||
WHERE "id" = (
|
|
||||||
SELECT "id"
|
|
||||||
FROM "beenvoice_user"
|
|
||||||
ORDER BY "createdAt" ASC
|
|
||||||
LIMIT 1
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE IF NOT EXISTS "beenvoice_platform_setting" (
|
|
||||||
"id" varchar(50) PRIMARY KEY DEFAULT 'global' NOT NULL,
|
|
||||||
"brandName" varchar(100) DEFAULT 'beenvoice' NOT NULL,
|
|
||||||
"brandTagline" varchar(255) DEFAULT 'Simple and efficient invoicing for freelancers and small businesses' NOT NULL,
|
|
||||||
"brandLogoText" varchar(100) DEFAULT 'beenvoice' NOT NULL,
|
|
||||||
"brandIcon" varchar(20) DEFAULT '$' NOT NULL,
|
|
||||||
"colorTheme" varchar(50) DEFAULT 'slate' NOT NULL,
|
|
||||||
"customColor" varchar(50),
|
|
||||||
"theme" varchar(20) DEFAULT 'system' NOT NULL,
|
|
||||||
"interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL,
|
|
||||||
"bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
|
|
||||||
"headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
|
|
||||||
"radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL,
|
|
||||||
"sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL,
|
|
||||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
INSERT INTO "beenvoice_platform_setting" (
|
|
||||||
"id",
|
|
||||||
"brandName",
|
|
||||||
"brandTagline",
|
|
||||||
"brandLogoText",
|
|
||||||
"brandIcon",
|
|
||||||
"colorTheme",
|
|
||||||
"customColor",
|
|
||||||
"theme",
|
|
||||||
"interfaceTheme",
|
|
||||||
"bodyFontPreference",
|
|
||||||
"headingFontPreference",
|
|
||||||
"radiusPreference",
|
|
||||||
"sidebarStyle"
|
|
||||||
) VALUES (
|
|
||||||
'global',
|
|
||||||
'beenvoice',
|
|
||||||
'Simple and efficient invoicing for freelancers and small businesses',
|
|
||||||
'beenvoice',
|
|
||||||
'$',
|
|
||||||
'slate',
|
|
||||||
NULL,
|
|
||||||
'system',
|
|
||||||
'beenvoice',
|
|
||||||
'brand',
|
|
||||||
'brand',
|
|
||||||
'xl',
|
|
||||||
'floating'
|
|
||||||
) ON CONFLICT ("id") DO NOTHING;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
ALTER TABLE "beenvoice_platform_setting"
|
|
||||||
ADD COLUMN "pdfTemplate" varchar(20) DEFAULT 'classic' NOT NULL;
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_platform_setting"
|
|
||||||
ADD COLUMN "pdfAccentColor" varchar(50) DEFAULT '#111827' NOT NULL;
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_platform_setting"
|
|
||||||
ADD COLUMN "pdfFooterText" varchar(120) DEFAULT 'Professional Invoicing' NOT NULL;
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_platform_setting"
|
|
||||||
ADD COLUMN "pdfShowLogo" boolean DEFAULT true NOT NULL;
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "beenvoice_platform_setting"
|
|
||||||
ADD COLUMN "pdfShowPageNumbers" boolean DEFAULT true NOT NULL;
|
|
||||||
+323
-698
File diff suppressed because it is too large
Load Diff
+259
-1194
File diff suppressed because it is too large
Load Diff
@@ -1,55 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "sqlite",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "6",
|
||||||
"when": 1775354242672,
|
"when": 1752275489999,
|
||||||
"tag": "0000_glossy_magneto",
|
"tag": "0000_unique_loa",
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 1,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775356013998,
|
|
||||||
"tag": "0001_supreme_the_enforcers",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 2,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775400000000,
|
|
||||||
"tag": "0002_tax_deductible",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 3,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775600000000,
|
|
||||||
"tag": "0003_appearance_preferences",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 4,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1777336000000,
|
|
||||||
"tag": "0004_platform_appearance_controls",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 5,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1777337000000,
|
|
||||||
"tag": "0005_platform_settings_and_roles",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 6,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1777338000000,
|
|
||||||
"tag": "0006_pdf_generation_settings",
|
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+6
-2
@@ -1,13 +1,17 @@
|
|||||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
// @ts-ignore -- no types for this plugin
|
// @ts-ignore -- no types for this plugin
|
||||||
import drizzle from "eslint-plugin-drizzle";
|
import drizzle from "eslint-plugin-drizzle";
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: import.meta.dirname,
|
||||||
|
});
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: [".next"],
|
ignores: [".next"],
|
||||||
},
|
},
|
||||||
...nextCoreWebVitals,
|
...compat.extends("next/core-web-vitals"),
|
||||||
{
|
{
|
||||||
files: ["**/*.ts", "**/*.tsx"],
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
+3
-2
@@ -6,8 +6,9 @@ import "./src/env.js";
|
|||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
output: "standalone",
|
eslint: {
|
||||||
serverExternalPackages: ["pg"],
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
Generated
+8182
File diff suppressed because it is too large
Load Diff
+64
-71
@@ -5,112 +5,105 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"check": "eslint . && tsc --noEmit",
|
"check": "next lint && tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "bun drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:clone": "./scripts/clone-local.sh",
|
"db:push-local-to-live": "node scripts/migrate-to-turso.js",
|
||||||
"docker:up": "colima start && docker compose up -d",
|
"db:push-simple": "node scripts/migrate-simple.js",
|
||||||
"docker:down": "docker compose down && colima stop",
|
"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",
|
||||||
"deploy": "drizzle-kit push && next build",
|
"deploy": "drizzle-kit push && next build",
|
||||||
"dev": "next dev --turbo",
|
"dev": "next dev --turbo",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"lint": "eslint .",
|
"lint": "next lint",
|
||||||
"lint:fix": "eslint --fix .",
|
"lint:fix": "next lint --fix",
|
||||||
"preview": "next build && next start",
|
"preview": "next build && next start",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/sso": "^1.4.12",
|
"@auth/drizzle-adapter": "^1.7.2",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@libsql/client": "^0.14.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
"@react-pdf/renderer": "^4.3.1",
|
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.69.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tiptap/extension-color": "^3.13.0",
|
"@trpc/client": "^11.0.0",
|
||||||
"@tiptap/extension-list-item": "^3.13.0",
|
"@trpc/react-query": "^11.0.0",
|
||||||
"@tiptap/extension-text-align": "^3.13.0",
|
"@trpc/server": "^11.0.0",
|
||||||
"@tiptap/extension-text-style": "^3.13.0",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@tiptap/react": "^3.13.0",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@tiptap/starter-kit": "^3.13.0",
|
"bcryptjs": "^3.0.2",
|
||||||
"@trpc/client": "^11.7.2",
|
"chrono-node": "^2.8.3",
|
||||||
"@trpc/react-query": "^11.7.2",
|
|
||||||
"@trpc/server": "^11.7.2",
|
|
||||||
"bcryptjs": "^3.0.3",
|
|
||||||
"better-auth": "^1.4.12",
|
|
||||||
"chrono-node": "^2.9.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^17.2.3",
|
"drizzle-orm": "^0.41.0",
|
||||||
"drizzle-orm": "^0.44.7",
|
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"framer-motion": "^12.23.26",
|
"lucide": "^0.525.0",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "^16.2.2",
|
"next": "^15.4.1",
|
||||||
"pg": "8.13.1",
|
"next-auth": "5.0.0-beta.25",
|
||||||
"react": "^19.2.4",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^9.12.0",
|
"react-day-picker": "^9.8.0",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.0.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"recharts": "^3.5.1",
|
|
||||||
"resend": "^4.8.0",
|
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.6",
|
||||||
"superjson": "^2.2.6",
|
"superjson": "^2.2.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zod": "^3.25.76"
|
"turso": "^0.1.0",
|
||||||
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@tailwindcss/postcss": "^4.0.15",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20.19.26",
|
"@types/node": "^20.14.10",
|
||||||
"@types/pg": "^8.16.0",
|
|
||||||
"@types/raf": "^3.4.3",
|
"@types/raf": "^3.4.3",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.0.0",
|
||||||
"baseline-browser-mapping": "^2.9.6",
|
"better-sqlite3": "^12.2.0",
|
||||||
"drizzle-kit": "^0.30.6",
|
"drizzle-kit": "^0.30.5",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.23.0",
|
||||||
"eslint-config-next": "^16.0.10",
|
"eslint-config-next": "^15.2.3",
|
||||||
"eslint-plugin-drizzle": "^0.2.3",
|
"eslint-plugin-drizzle": "^0.2.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.3",
|
||||||
"prettier": "3.6.2",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.0.15",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.8.2",
|
||||||
"typescript-eslint": "^8.49.0"
|
"typescript-eslint": "^8.27.0"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.39.3"
|
"initVersion": "7.39.3"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@tailwindcss/oxide",
|
"@tailwindcss/oxide",
|
||||||
|
"better-sqlite3",
|
||||||
"core-js",
|
"core-js",
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"sharp",
|
"sharp",
|
||||||
|
|||||||
+1
-3
@@ -1,7 +1,5 @@
|
|||||||
const config = {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
"@tailwindcss/postcss": {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
|
||||||
|
|||||||
+1
-3
@@ -1,6 +1,4 @@
|
|||||||
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||||
const config = {
|
export default {
|
||||||
plugins: ["prettier-plugin-tailwindcss"],
|
plugins: ["prettier-plugin-tailwindcss"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 72 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 85 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 0 B |
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.
@@ -1,71 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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);
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
#!/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
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { toNextJsHandler } from "better-auth/next-js";
|
|
||||||
import { auth } from "~/lib/auth";
|
|
||||||
|
|
||||||
export const { GET, POST } = toNextJsHandler(auth);
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "~/server/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { db } from "~/server/db";
|
|
||||||
import { users } from "~/server/db/schema";
|
|
||||||
import { Resend } from "resend";
|
|
||||||
import { env } from "~/env";
|
|
||||||
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
|
||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { email } = (await request.json()) as { email: string };
|
|
||||||
|
|
||||||
if (!email || typeof email !== "string") {
|
|
||||||
return NextResponse.json({ error: "Email is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate email format
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(email)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid email format" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user exists
|
|
||||||
const user = await db.query.users.findFirst({
|
|
||||||
where: eq(users.email, email.toLowerCase()),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Always return success to prevent email enumeration attacks
|
|
||||||
// Don't reveal whether the user exists or not
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
message:
|
|
||||||
"If an account with that email exists, password reset instructions have been sent.",
|
|
||||||
},
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate reset token
|
|
||||||
const resetToken = crypto.randomBytes(32).toString("hex");
|
|
||||||
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
|
||||||
|
|
||||||
// Update user with reset token
|
|
||||||
await db
|
|
||||||
.update(users)
|
|
||||||
.set({
|
|
||||||
resetToken,
|
|
||||||
resetTokenExpiry,
|
|
||||||
})
|
|
||||||
.where(eq(users.id, user.id));
|
|
||||||
|
|
||||||
if (!env.RESEND_API_KEY) {
|
|
||||||
console.warn(
|
|
||||||
"Password reset requested, but RESEND_API_KEY is not configured.",
|
|
||||||
);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
message:
|
|
||||||
"If an account with that email exists, password reset instructions have been sent.",
|
|
||||||
},
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send password reset email using Resend
|
|
||||||
try {
|
|
||||||
const resend = new Resend(env.RESEND_API_KEY);
|
|
||||||
const resetUrl = `${process.env.BETTER_AUTH_URL ?? "http://localhost:3000"}/auth/reset-password?token=${resetToken}`;
|
|
||||||
|
|
||||||
const emailTemplate = generatePasswordResetEmailTemplate({
|
|
||||||
userEmail: email,
|
|
||||||
userName: user.name ?? undefined,
|
|
||||||
resetToken,
|
|
||||||
resetUrl,
|
|
||||||
expiryHours: 24,
|
|
||||||
});
|
|
||||||
|
|
||||||
await resend.emails.send({
|
|
||||||
from: "beenvoice <noreply@beenvoice.com>",
|
|
||||||
to: email,
|
|
||||||
subject: emailTemplate.subject,
|
|
||||||
html: emailTemplate.html,
|
|
||||||
text: emailTemplate.text,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Password reset email sent to: ${email}`);
|
|
||||||
} catch (emailError) {
|
|
||||||
console.error("Failed to send password reset email:", emailError);
|
|
||||||
// Continue execution - don't fail the request if email fails
|
|
||||||
// This prevents revealing whether an account exists based on email delivery
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
message:
|
|
||||||
"If an account with that email exists, password reset instructions have been sent.",
|
|
||||||
},
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Password reset error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "An error occurred while processing your request" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+143
-207
@@ -1,19 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useState, Suspense } from "react";
|
import { useState, Suspense } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Logo } from "~/components/branding/logo";
|
import { Logo } from "~/components/branding/logo";
|
||||||
import { LegalModal } from "~/components/ui/legal-modal";
|
import { User, Mail, Lock, ArrowRight } from "lucide-react";
|
||||||
import { Mail, Lock, ArrowRight, User, Clock, Rocket, Zap } from "lucide-react";
|
|
||||||
|
|
||||||
function RegisterForm() {
|
function RegisterForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||||
const [firstName, setFirstName] = useState("");
|
const [firstName, setFirstName] = useState("");
|
||||||
const [lastName, setLastName] = useState("");
|
const [lastName, setLastName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -23,238 +24,173 @@ function RegisterForm() {
|
|||||||
async function handleRegister(e: React.FormEvent) {
|
async function handleRegister(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const res = await fetch("/api/auth/register", {
|
const res = await fetch("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: `${firstName} ${lastName}`,
|
firstName,
|
||||||
|
lastName,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success("Account created successfully! Please sign in.");
|
toast.success("Account created successfully! Please sign in.");
|
||||||
router.push("/auth/signin");
|
const signInUrl =
|
||||||
|
callbackUrl !== "/dashboard"
|
||||||
|
? `/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||||
|
: "/auth/signin";
|
||||||
|
router.push(signInUrl);
|
||||||
} else {
|
} else {
|
||||||
const data = (await res.json()) as { error?: string };
|
const error = await res.text();
|
||||||
toast.error(data.error ?? "Registration failed");
|
toast.error(error || "Failed to create account");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex min-h-screen items-center justify-center">
|
<div className="auth-container">
|
||||||
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:border md:shadow-lg">
|
<div className="auth-form-container">
|
||||||
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
{/* Logo and Welcome */}
|
||||||
{/* Hero Section - Hidden on mobile */}
|
<div className="auth-header">
|
||||||
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
|
<Logo size="lg" className="mx-auto" />
|
||||||
<div className="space-y-8">
|
<div>
|
||||||
<div className="space-y-4">
|
<h1 className="auth-title">Join beenvoice</h1>
|
||||||
<div className="flex items-center space-x-2">
|
<p className="auth-subtitle">Create your account to get started</p>
|
||||||
<Logo size="xl" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h1 className="text-3xl font-bold lg:text-4xl">
|
|
||||||
Start your
|
|
||||||
<span className="text-primary"> invoicing journey</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-lg">
|
|
||||||
Join thousands of freelancers and small businesses who trust
|
|
||||||
beenvoice to manage their invoicing and get paid faster.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div 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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sign Up Form */}
|
{/* Registration Form */}
|
||||||
<div className="flex flex-col justify-center p-6 md:p-12">
|
<Card className="auth-card">
|
||||||
<div className="mx-auto w-full max-w-sm space-y-6">
|
<CardHeader className="space-y-1">
|
||||||
{/* Mobile Logo */}
|
<CardTitle className="auth-card-title">Create Account</CardTitle>
|
||||||
<div className="flex justify-center md:hidden">
|
</CardHeader>
|
||||||
<Logo size="lg" />
|
<CardContent>
|
||||||
|
<form onSubmit={handleRegister} className="auth-form">
|
||||||
|
<div className="auth-input-grid">
|
||||||
|
<div className="auth-input-group">
|
||||||
|
<Label htmlFor="firstName">First Name</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="auth-input-icon" />
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="form-input-with-icon"
|
||||||
|
placeholder="First name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="auth-input-group">
|
||||||
|
<Label htmlFor="lastName">Last Name</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="auth-input-icon" />
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="form-input-with-icon"
|
||||||
|
placeholder="Last name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="auth-input-group">
|
||||||
<div className="space-y-2 text-center md:text-left">
|
<Label htmlFor="email">Email</Label>
|
||||||
<h1 className="text-2xl font-bold">Create your account</h1>
|
<div className="relative">
|
||||||
<p className="text-muted-foreground">
|
<Mail className="auth-input-icon" />
|
||||||
Supercharge your invoicing today
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="form-input-with-icon"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="auth-input-group">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="auth-input-icon" />
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="auth-password-help">
|
||||||
|
Must be at least 6 characters
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
<form onSubmit={handleRegister} className="space-y-4">
|
type="submit"
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
className="auth-submit-btn"
|
||||||
<div className="space-y-2">
|
disabled={loading}
|
||||||
<Label htmlFor="firstName">First Name</Label>
|
>
|
||||||
<div className="relative">
|
{loading ? (
|
||||||
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
"Creating account..."
|
||||||
<Input
|
) : (
|
||||||
id="firstName"
|
<>
|
||||||
type="text"
|
Create Account
|
||||||
value={firstName}
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
onChange={(e) => setFirstName(e.target.value)}
|
</>
|
||||||
required
|
)}
|
||||||
autoFocus
|
</Button>
|
||||||
className="h-11 pl-10"
|
</form>
|
||||||
placeholder="John"
|
<div className="auth-footer-text">
|
||||||
/>
|
<span className="text-muted-foreground">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="lastName">Last Name</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<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="h-11 pl-10"
|
|
||||||
placeholder="Doe"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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
|
|
||||||
className="h-11 pl-10"
|
|
||||||
placeholder="john@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="password">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="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
className="h-11 pl-10"
|
|
||||||
placeholder="Create a strong password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Must be at least 8 characters long
|
|
||||||
</p>
|
|
||||||
</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>Creating account...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span>Create Account</span>
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="text-center text-sm">
|
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<a
|
</span>
|
||||||
href="/auth/signin"
|
<Link href="/auth/signin" className="auth-footer-link">
|
||||||
className="text-primary font-medium hover:underline"
|
Sign in here
|
||||||
>
|
</Link>
|
||||||
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>
|
|
||||||
}
|
|
||||||
/>{" "}
|
|
||||||
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>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<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>
|
||||||
|
}
|
||||||
|
>
|
||||||
<RegisterForm />
|
<RegisterForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,462 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+103
-237
@@ -1,28 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useState, Suspense } from "react";
|
import { useState, Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { authClient } from "~/lib/auth-client";
|
import { signIn } from "next-auth/react";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Logo } from "~/components/branding/logo";
|
import { Logo } from "~/components/branding/logo";
|
||||||
import { LegalModal } from "~/components/ui/legal-modal";
|
import { Mail, Lock, ArrowRight } from "lucide-react";
|
||||||
import { env } from "~/env";
|
|
||||||
import {
|
|
||||||
Mail,
|
|
||||||
Lock,
|
|
||||||
ArrowRight,
|
|
||||||
Users,
|
|
||||||
FileText,
|
|
||||||
TrendingUp,
|
|
||||||
Shield,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
function SignInForm() {
|
function SignInForm() {
|
||||||
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||||
@@ -34,15 +24,16 @@ function SignInForm() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const { error } = await authClient.signIn.email({
|
const result = await signIn("credentials", {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
|
redirect: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
if (error) {
|
if (result?.error) {
|
||||||
toast.error(error.message ?? "Invalid email or password");
|
toast.error("Invalid email or password");
|
||||||
} else {
|
} else {
|
||||||
toast.success("Signed in successfully!");
|
toast.success("Signed in successfully!");
|
||||||
router.push(callbackUrl);
|
router.push(callbackUrl);
|
||||||
@@ -50,240 +41,115 @@ 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 (
|
return (
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
|
<div className="bg-gradient-auth flex min-h-screen items-center justify-center p-4">
|
||||||
{/* Blob Background */}
|
<div className="w-full max-w-md space-y-8">
|
||||||
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
|
{/* Logo and Welcome */}
|
||||||
<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="space-y-4 text-center">
|
||||||
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
|
<Logo size="lg" className="mx-auto" />
|
||||||
</div>
|
<div>
|
||||||
|
<h1 className="text-foreground text-2xl font-bold">Welcome back</h1>
|
||||||
<Card className="md:bg-background/80 md:border-border/50 mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:rounded-3xl md:border md:shadow-2xl md:backdrop-blur-xl">
|
<p className="text-muted-foreground mt-2">
|
||||||
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
Sign in to your beenvoice account
|
||||||
{/* Hero Section - Hidden on mobile */}
|
</p>
|
||||||
<div className="bg-primary/5 border-border/50 relative hidden border-r md:flex md:flex-col md:justify-center md:p-12">
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Logo size="xl" />
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h1 className="font-heading text-3xl font-bold lg:text-4xl">
|
|
||||||
Welcome back to your
|
|
||||||
<span className="text-primary italic">
|
|
||||||
{" "}
|
|
||||||
invoicing workspace
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-lg">
|
|
||||||
Continue managing your clients and creating professional
|
|
||||||
invoices that get you paid faster.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6">
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
<div className="bg-primary/10 rounded-xl p-3">
|
|
||||||
<Users className="text-primary h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-foreground font-semibold">
|
|
||||||
Client Management
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Organize and track all your clients in one place
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
<div className="bg-primary/10 rounded-xl p-3">
|
|
||||||
<FileText className="text-primary h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-foreground font-semibold">
|
|
||||||
Professional Invoices
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Beautiful templates that get you paid faster
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
<div className="bg-primary/10 rounded-xl p-3">
|
|
||||||
<TrendingUp className="text-primary h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-foreground font-semibold">
|
|
||||||
Payment Tracking
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Monitor your income with real-time insights
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sign In Form */}
|
{/* Sign In Form */}
|
||||||
<div className="flex flex-col justify-center p-6 md:p-12">
|
<Card className="card-primary">
|
||||||
<div className="mx-auto w-full max-w-sm space-y-6">
|
<CardHeader className="space-y-1">
|
||||||
{/* Mobile Logo */}
|
<CardTitle className="text-center text-xl">Sign In</CardTitle>
|
||||||
<div className="flex justify-center md:hidden">
|
</CardHeader>
|
||||||
<Logo size="lg" />
|
<CardContent>
|
||||||
|
<form onSubmit={handleSignIn} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="form-icon-left" />
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="form-input-with-icon"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="space-y-2 text-center md:text-left">
|
<Label htmlFor="password">Password</Label>
|
||||||
<h1 className="font-heading text-3xl font-bold">Sign In</h1>
|
<div className="relative">
|
||||||
<p className="text-muted-foreground">
|
<Lock className="form-icon-left" />
|
||||||
Enter your credentials to access your account
|
<Input
|
||||||
</p>
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="form-input-with-icon"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
{authentikEnabled && (
|
{loading ? (
|
||||||
<div className="space-y-4">
|
"Signing in..."
|
||||||
<Button
|
) : (
|
||||||
variant="outline"
|
<>
|
||||||
type="button"
|
Sign In
|
||||||
className="relative h-11 w-full rounded-xl"
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
onClick={handleSocialSignIn}
|
</>
|
||||||
disabled={loading}
|
)}
|
||||||
>
|
</Button>
|
||||||
<Shield className="mr-2 h-4 w-4" />
|
</form>
|
||||||
Sign in with Authentik
|
<div className="mt-6 text-center text-sm">
|
||||||
</Button>
|
<span className="text-muted-foreground">
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="border-border/50 w-full border-t" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-background text-muted-foreground px-2">
|
|
||||||
Or continue with
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSignIn} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email Address</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
|
|
||||||
placeholder="m@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<a
|
|
||||||
href="/auth/forgot-password"
|
|
||||||
className="text-primary text-sm hover:underline"
|
|
||||||
>
|
|
||||||
Forgot password?
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="shadow-primary/20 hover:shadow-primary/30 h-11 w-full rounded-xl text-base shadow-lg"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
|
|
||||||
<span>Signing in...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span>Sign In</span>
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="text-center text-sm">
|
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<a
|
</span>
|
||||||
href="/auth/register"
|
<Link href="/auth/register" className="nav-link-brand">
|
||||||
className="text-primary font-medium hover:underline"
|
Create one now
|
||||||
>
|
</Link>
|
||||||
Sign up
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-muted-foreground text-center text-xs leading-relaxed">
|
|
||||||
By signing in, you agree to our{" "}
|
|
||||||
<LegalModal
|
|
||||||
type="terms"
|
|
||||||
trigger={
|
|
||||||
<span className="text-primary inline cursor-pointer hover:underline">
|
|
||||||
Terms of Service
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>{" "}
|
|
||||||
and{" "}
|
|
||||||
<LegalModal
|
|
||||||
type="privacy"
|
|
||||||
trigger={
|
|
||||||
<span className="text-primary inline cursor-pointer hover:underline">
|
|
||||||
Privacy Policy
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignInPage() {
|
export default function SignInPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<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>
|
||||||
|
}
|
||||||
|
>
|
||||||
<SignInForm />
|
<SignInForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import {
|
|
||||||
Send,
|
|
||||||
DollarSign,
|
|
||||||
FileText,
|
|
||||||
AlertCircle,
|
|
||||||
Clock,
|
|
||||||
CheckCircle,
|
|
||||||
RefreshCw,
|
|
||||||
Calendar,
|
|
||||||
Loader2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "~/components/ui/alert-dialog";
|
|
||||||
import {
|
|
||||||
getEffectiveInvoiceStatus,
|
|
||||||
isInvoiceOverdue,
|
|
||||||
getDaysPastDue,
|
|
||||||
getStatusConfig,
|
|
||||||
} from "~/lib/invoice-status";
|
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
|
||||||
|
|
||||||
interface StatusManagerProps {
|
|
||||||
invoiceId: string;
|
|
||||||
currentStatus: StoredInvoiceStatus;
|
|
||||||
dueDate: Date;
|
|
||||||
clientEmail?: string | null;
|
|
||||||
onStatusChange?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusIconConfig = {
|
|
||||||
draft: FileText,
|
|
||||||
sent: Send,
|
|
||||||
paid: CheckCircle,
|
|
||||||
overdue: AlertCircle,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function StatusManager({
|
|
||||||
invoiceId,
|
|
||||||
currentStatus,
|
|
||||||
dueDate,
|
|
||||||
clientEmail,
|
|
||||||
onStatusChange,
|
|
||||||
}: StatusManagerProps) {
|
|
||||||
const [isChangingStatus, setIsChangingStatus] = useState(false);
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const updateStatus = api.invoices.updateStatus.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast.success(data.message);
|
|
||||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
|
||||||
void utils.invoices.getAll.invalidate();
|
|
||||||
onStatusChange?.();
|
|
||||||
setIsChangingStatus(false);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message ?? "Failed to update status");
|
|
||||||
setIsChangingStatus(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const sendEmail = api.email.sendInvoice.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast.success(data.message);
|
|
||||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
|
||||||
void utils.invoices.getAll.invalidate();
|
|
||||||
onStatusChange?.();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleStatusUpdate = async (newStatus: StoredInvoiceStatus) => {
|
|
||||||
setIsChangingStatus(true);
|
|
||||||
updateStatus.mutate({
|
|
||||||
id: invoiceId,
|
|
||||||
status: newStatus,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendEmail = () => {
|
|
||||||
sendEmail.mutate({ invoiceId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const effectiveStatus = getEffectiveInvoiceStatus(currentStatus, dueDate);
|
|
||||||
const isOverdue = isInvoiceOverdue(currentStatus, dueDate);
|
|
||||||
const daysPastDue = getDaysPastDue(currentStatus, dueDate);
|
|
||||||
const statusConfig = getStatusConfig(currentStatus, dueDate);
|
|
||||||
|
|
||||||
const StatusIcon = statusIconConfig[effectiveStatus];
|
|
||||||
|
|
||||||
const getAvailableActions = () => {
|
|
||||||
const actions = [];
|
|
||||||
|
|
||||||
switch (effectiveStatus) {
|
|
||||||
case "draft":
|
|
||||||
if (clientEmail) {
|
|
||||||
actions.push({
|
|
||||||
key: "send",
|
|
||||||
label: "Send Invoice",
|
|
||||||
action: handleSendEmail,
|
|
||||||
variant: "default" as const,
|
|
||||||
icon: Send,
|
|
||||||
disabled: sendEmail.isPending,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
actions.push({
|
|
||||||
key: "markPaid",
|
|
||||||
label: "Mark as Paid",
|
|
||||||
action: () => handleStatusUpdate("paid"),
|
|
||||||
variant: "secondary" as const,
|
|
||||||
icon: DollarSign,
|
|
||||||
disabled: isChangingStatus,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "sent":
|
|
||||||
actions.push({
|
|
||||||
key: "markPaid",
|
|
||||||
label: "Mark as Paid",
|
|
||||||
action: () => handleStatusUpdate("paid"),
|
|
||||||
variant: "default" as const,
|
|
||||||
icon: DollarSign,
|
|
||||||
disabled: isChangingStatus,
|
|
||||||
});
|
|
||||||
if (clientEmail) {
|
|
||||||
actions.push({
|
|
||||||
key: "resend",
|
|
||||||
label: "Resend Invoice",
|
|
||||||
action: handleSendEmail,
|
|
||||||
variant: "outline" as const,
|
|
||||||
icon: Send,
|
|
||||||
disabled: sendEmail.isPending,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
actions.push({
|
|
||||||
key: "backToDraft",
|
|
||||||
label: "Back to Draft",
|
|
||||||
action: () => handleStatusUpdate("draft"),
|
|
||||||
variant: "outline" as const,
|
|
||||||
icon: FileText,
|
|
||||||
disabled: isChangingStatus,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "overdue":
|
|
||||||
actions.push({
|
|
||||||
key: "markPaid",
|
|
||||||
label: "Mark as Paid",
|
|
||||||
action: () => handleStatusUpdate("paid"),
|
|
||||||
variant: "default" as const,
|
|
||||||
icon: DollarSign,
|
|
||||||
disabled: isChangingStatus,
|
|
||||||
});
|
|
||||||
if (clientEmail) {
|
|
||||||
actions.push({
|
|
||||||
key: "resend",
|
|
||||||
label: "Resend Invoice",
|
|
||||||
action: handleSendEmail,
|
|
||||||
variant: "outline" as const,
|
|
||||||
icon: Send,
|
|
||||||
disabled: sendEmail.isPending,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
actions.push({
|
|
||||||
key: "backToSent",
|
|
||||||
label: "Mark as Sent",
|
|
||||||
action: () => handleStatusUpdate("sent"),
|
|
||||||
variant: "outline" as const,
|
|
||||||
icon: Clock,
|
|
||||||
disabled: isChangingStatus,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "paid":
|
|
||||||
// Paid invoices can be reverted if needed (rare cases)
|
|
||||||
actions.push({
|
|
||||||
key: "revert",
|
|
||||||
label: "Revert to Sent",
|
|
||||||
action: () => handleStatusUpdate("sent"),
|
|
||||||
variant: "outline" as const,
|
|
||||||
icon: RefreshCw,
|
|
||||||
disabled: isChangingStatus,
|
|
||||||
requireConfirmation: true,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions;
|
|
||||||
};
|
|
||||||
|
|
||||||
const actions = getAvailableActions();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-lg">
|
|
||||||
<StatusIcon className="h-5 w-5" />
|
|
||||||
Invoice Status
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* Current Status Display */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Badge className={statusConfig.color} variant="secondary">
|
|
||||||
{statusConfig.label}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-muted-foreground text-sm">
|
|
||||||
{statusConfig.description}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overdue Warning */}
|
|
||||||
{isOverdue && (
|
|
||||||
<div className="bg-destructive/10 text-destructive flex items-center gap-2 p-3">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{daysPastDue} day{daysPastDue !== 1 ? "s" : ""} overdue
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Due Date Info */}
|
|
||||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
<span>
|
|
||||||
Due:{" "}
|
|
||||||
{new Intl.DateTimeFormat("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
}).format(new Date(dueDate))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
{actions.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-foreground text-sm font-medium">
|
|
||||||
Available Actions:
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
{actions.map((action) => {
|
|
||||||
const ActionIcon = action.icon;
|
|
||||||
|
|
||||||
if (action.requireConfirmation) {
|
|
||||||
return (
|
|
||||||
<AlertDialog key={action.key}>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={action.variant}
|
|
||||||
size="sm"
|
|
||||||
disabled={action.disabled}
|
|
||||||
className="w-full justify-start"
|
|
||||||
>
|
|
||||||
<ActionIcon className="mr-2 h-4 w-4" />
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
Confirm Status Change
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Are you sure you want to change this invoice status?
|
|
||||||
This action may affect your records.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={action.action}>
|
|
||||||
Confirm
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={action.key}
|
|
||||||
variant={action.variant}
|
|
||||||
size="sm"
|
|
||||||
onClick={action.action}
|
|
||||||
disabled={action.disabled}
|
|
||||||
className="w-full justify-start"
|
|
||||||
>
|
|
||||||
{action.disabled &&
|
|
||||||
(action.key === "send" || action.key === "resend") ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : action.disabled &&
|
|
||||||
(action.key === "markPaid" ||
|
|
||||||
action.key === "backToDraft" ||
|
|
||||||
action.key === "backToSent") ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<ActionIcon className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* No Email Warning */}
|
|
||||||
{!clientEmail && effectiveStatus !== "paid" && (
|
|
||||||
<div className="bg-muted text-muted-foreground p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
No email address on file for this client
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs">
|
|
||||||
Add an email address to the client to enable sending invoices.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,7 @@ export default async function BusinessDetailPage({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<div className="space-y-6 pb-32">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
|
title={business.name}
|
||||||
description="View business details and information"
|
description="View business details and information"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
@@ -55,7 +55,7 @@ export default async function BusinessDetailPage({
|
|||||||
<span>Back to Businesses</span>
|
<span>Back to Businesses</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="default" className="shadow-md">
|
<Button asChild className="btn-brand-primary shadow-md">
|
||||||
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
<span>Edit Business</span>
|
<span>Edit Business</span>
|
||||||
@@ -66,11 +66,11 @@ export default async function BusinessDetailPage({
|
|||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* Business Information Card */}
|
{/* Business Information Card */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card className="bg-card border-border border">
|
<Card className="card-primary">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-blue-subtle rounded-lg p-2">
|
||||||
<Building className="text-primary h-5 w-5" />
|
<Building className="text-icon-blue h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span>Business Information</span>
|
<span>Business Information</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -84,8 +84,8 @@ export default async function BusinessDetailPage({
|
|||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{business.email && (
|
{business.email && (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-green-subtle rounded-lg p-2">
|
||||||
<Mail className="text-primary h-4 w-4" />
|
<Mail className="text-icon-green h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -100,8 +100,8 @@ export default async function BusinessDetailPage({
|
|||||||
|
|
||||||
{business.phone && (
|
{business.phone && (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-green-subtle rounded-lg p-2">
|
||||||
<Phone className="text-primary h-4 w-4" />
|
<Phone className="text-icon-green h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -116,8 +116,8 @@ export default async function BusinessDetailPage({
|
|||||||
|
|
||||||
{business.website && (
|
{business.website && (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-green-subtle rounded-lg p-2">
|
||||||
<Globe className="text-primary h-4 w-4" />
|
<Globe className="text-icon-green h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -137,8 +137,8 @@ export default async function BusinessDetailPage({
|
|||||||
|
|
||||||
{business.taxId && (
|
{business.taxId && (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-green-subtle rounded-lg p-2">
|
||||||
<Hash className="text-primary h-4 w-4" />
|
<Hash className="text-icon-green h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -162,8 +162,8 @@ export default async function BusinessDetailPage({
|
|||||||
Business Address
|
Business Address
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-green-subtle rounded-lg p-2">
|
||||||
<MapPin className="text-primary h-4 w-4" />
|
<MapPin className="text-icon-green h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
{business.addressLine1 && (
|
{business.addressLine1 && (
|
||||||
@@ -205,8 +205,8 @@ export default async function BusinessDetailPage({
|
|||||||
<h3 className="mb-4 text-lg font-semibold">Business Details</h3>
|
<h3 className="mb-4 text-lg font-semibold">Business Details</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-green-subtle rounded-lg p-2">
|
||||||
<Calendar className="text-primary h-4 w-4" />
|
<Calendar className="text-icon-green h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -218,32 +218,11 @@ export default async function BusinessDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{business.nickname && (
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="bg-primary/10 p-2">
|
|
||||||
<Building className="text-primary h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
|
||||||
Nickname
|
|
||||||
</p>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
Internal only
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-foreground text-sm">
|
|
||||||
{business.nickname}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Default Business Badge */}
|
{/* Default Business Badge */}
|
||||||
{business.isDefault && (
|
{business.isDefault && (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-green-subtle rounded-lg p-2">
|
||||||
<Building className="text-primary h-4 w-4" />
|
<Building className="text-icon-green h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -251,7 +230,7 @@ export default async function BusinessDetailPage({
|
|||||||
</p>
|
</p>
|
||||||
<Badge
|
<Badge
|
||||||
variant="default"
|
variant="default"
|
||||||
className="bg-primary/10 text-primary"
|
className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||||
>
|
>
|
||||||
Default Business
|
Default Business
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -266,11 +245,11 @@ export default async function BusinessDetailPage({
|
|||||||
|
|
||||||
{/* Settings & Actions Card */}
|
{/* Settings & Actions Card */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="bg-card border-border border">
|
<Card className="card-primary">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-blue-subtle rounded-lg p-2">
|
||||||
<Building className="text-primary h-5 w-5" />
|
<Building className="text-icon-blue h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span>Quick Actions</span>
|
<span>Quick Actions</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -302,7 +281,7 @@ export default async function BusinessDetailPage({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Information Card */}
|
{/* Information Card */}
|
||||||
<Card className="bg-card border-border border">
|
<Card className="card-primary">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">About This Business</CardTitle>
|
<CardTitle className="text-lg">About This Business</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -313,7 +292,7 @@ export default async function BusinessDetailPage({
|
|||||||
represents your company information to clients.
|
represents your company information to clients.
|
||||||
</p>
|
</p>
|
||||||
{business.isDefault && (
|
{business.isDefault && (
|
||||||
<p className="text-primary">
|
<p className="text-green-600 dark:text-green-400">
|
||||||
This is your default business and will be automatically
|
This is your default business and will be automatically
|
||||||
selected when creating new invoices.
|
selected when creating new invoices.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { toast } from "sonner";
|
|||||||
interface Business {
|
interface Business {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
nickname: string | null;
|
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
addressLine1: string | null;
|
addressLine1: string | null;
|
||||||
@@ -43,6 +42,17 @@ interface BusinessesDataTableProps {
|
|||||||
businesses: Business[];
|
businesses: Business[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatAddress = (business: Business) => {
|
||||||
|
const parts = [
|
||||||
|
business.addressLine1,
|
||||||
|
business.addressLine2,
|
||||||
|
business.city,
|
||||||
|
business.state,
|
||||||
|
business.postalCode,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(", ") || "—";
|
||||||
|
};
|
||||||
|
|
||||||
export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [businessToDelete, setBusinessToDelete] = useState<Business | null>(
|
const [businessToDelete, setBusinessToDelete] = useState<Business | null>(
|
||||||
@@ -51,11 +61,6 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const searchableBusinesses = businesses.map((b) => ({
|
|
||||||
...b,
|
|
||||||
searchValue: `${b.name} ${b.nickname ?? ""}`.trim(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const deleteBusinessMutation = api.businesses.delete.useMutation({
|
const deleteBusinessMutation = api.businesses.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Business deleted successfully");
|
toast.success("Business deleted successfully");
|
||||||
@@ -86,30 +91,19 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
const business = row.original;
|
const business = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 hidden p-2 sm:flex">
|
<div className="bg-blue-subtle hidden rounded-lg p-2 sm:flex">
|
||||||
<Building className="text-primary h-4 w-4" />
|
<Building className="text-icon-blue h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium">{business.name}</p>
|
<p className="truncate font-medium">{business.name}</p>
|
||||||
<p className="text-muted-foreground truncate text-sm">
|
<p className="text-muted-foreground truncate text-sm">
|
||||||
{business.nickname ?? "—"}
|
{business.email ?? "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: "email",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataTableColumnHeader column={column} title="Email" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => row.original.email ?? "—",
|
|
||||||
meta: {
|
|
||||||
headerClassName: "hidden sm:table-cell",
|
|
||||||
cellClassName: "hidden sm:table-cell",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: "phone",
|
accessorKey: "phone",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@@ -121,6 +115,26 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
cellClassName: "hidden md:table-cell",
|
cellClassName: "hidden md:table-cell",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "address",
|
||||||
|
header: "Address",
|
||||||
|
cell: ({ row }) => formatAddress(row.original),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden lg:table-cell",
|
||||||
|
cellClassName: "hidden lg:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "taxId",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Tax ID" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => row.original.taxId ?? "—",
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden xl:table-cell",
|
||||||
|
cellClassName: "hidden xl:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "website",
|
accessorKey: "website",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@@ -161,15 +175,6 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: "searchValue",
|
|
||||||
header: "Search",
|
|
||||||
cell: () => null,
|
|
||||||
meta: {
|
|
||||||
headerClassName: "hidden",
|
|
||||||
cellClassName: "hidden",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -205,9 +210,9 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={searchableBusinesses}
|
data={businesses}
|
||||||
searchKey="searchValue"
|
searchKey="name"
|
||||||
searchPlaceholder="Search by name or nickname..."
|
searchPlaceholder="Search businesses..."
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -221,8 +226,8 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
<DialogTitle>Are you sure?</DialogTitle>
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
business "{businessToDelete?.name}" and remove all
|
business "{businessToDelete?.name}" and remove all associated
|
||||||
associated data.
|
data.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { BusinessForm } from "~/components/forms/business-form";
|
import { BusinessForm } from "~/components/forms/business-form";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
import { HydrateClient } from "~/trpc/server";
|
import { HydrateClient } from "~/trpc/server";
|
||||||
|
|
||||||
export default function NewBusinessPage() {
|
export default function NewBusinessPage() {
|
||||||
return (
|
return (
|
||||||
<HydrateClient>
|
<div className="space-y-6 pb-32">
|
||||||
<BusinessForm mode="create" />
|
<PageHeader
|
||||||
</HydrateClient>
|
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 Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
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 { 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 { BusinessesDataTable } from "./_components/businesses-data-table";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
|
|
||||||
// Businesses Table Component
|
// Businesses Table Component
|
||||||
async function BusinessesTable() {
|
async function BusinessesTable() {
|
||||||
@@ -16,13 +16,13 @@ async function BusinessesTable() {
|
|||||||
|
|
||||||
export default async function BusinessesPage() {
|
export default async function BusinessesPage() {
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-8">
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Businesses"
|
title="Businesses"
|
||||||
description="Manage your businesses and their information"
|
description="Manage your businesses and their information"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
<Button asChild className="btn-brand-primary shadow-md">
|
||||||
<Link href="/dashboard/businesses/new">
|
<Link href="/dashboard/businesses/new">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
<span>Add Business</span>
|
<span>Add Business</span>
|
||||||
@@ -31,10 +31,10 @@ export default async function BusinessesPage() {
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
<Suspense fallback={<DataTableSkeleton columns={6} rows={5} />}>
|
||||||
<BusinessesTable />
|
<BusinessesTable />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
|
||||||
|
|
||||||
interface ClientDetailPageProps {
|
interface ClientDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -69,7 +67,7 @@ export default async function ClientDetailPage({
|
|||||||
<span>Back to Clients</span>
|
<span>Back to Clients</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="default" className="shadow-md">
|
<Button asChild className="btn-brand-primary shadow-md">
|
||||||
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
<span>Edit Client</span>
|
<span>Edit Client</span>
|
||||||
@@ -80,11 +78,11 @@ export default async function ClientDetailPage({
|
|||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* Client Information Card */}
|
{/* Client Information Card */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card className="bg-card border-border border">
|
<Card className="card-primary">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-blue-subtle rounded-lg p-2">
|
||||||
<Building className="text-primary h-5 w-5" />
|
<Building className="text-icon-blue h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span>Contact Information</span>
|
<span>Contact Information</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -94,8 +92,8 @@ export default async function ClientDetailPage({
|
|||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{client.email && (
|
{client.email && (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-green-subtle rounded-lg p-2">
|
||||||
<Mail className="text-primary h-4 w-4" />
|
<Mail className="text-icon-green h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -108,8 +106,8 @@ export default async function ClientDetailPage({
|
|||||||
|
|
||||||
{client.phone && (
|
{client.phone && (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-green-subtle rounded-lg p-2">
|
||||||
<Phone className="text-primary h-4 w-4" />
|
<Phone className="text-icon-green h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -126,8 +124,8 @@ export default async function ClientDetailPage({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="mb-4 text-lg font-semibold">Client Address</h3>
|
<h3 className="mb-4 text-lg font-semibold">Client Address</h3>
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-green-subtle rounded-lg p-2">
|
||||||
<MapPin className="text-primary h-4 w-4" />
|
<MapPin className="text-icon-green h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
{client.addressLine1 && (
|
{client.addressLine1 && (
|
||||||
@@ -155,8 +153,8 @@ export default async function ClientDetailPage({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="mb-4 text-lg font-semibold">Client Details</h3>
|
<h3 className="mb-4 text-lg font-semibold">Client Details</h3>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-green-subtle rounded-lg p-2">
|
||||||
<Calendar className="text-primary h-4 w-4" />
|
<Calendar className="text-icon-green h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
@@ -174,11 +172,11 @@ export default async function ClientDetailPage({
|
|||||||
|
|
||||||
{/* Stats Card */}
|
{/* Stats Card */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="bg-card border-border border">
|
<Card className="card-primary">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-blue-subtle rounded-lg p-2">
|
||||||
<DollarSign className="text-primary h-5 w-5" />
|
<DollarSign className="text-icon-blue h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span>Invoice Summary</span>
|
<span>Invoice Summary</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -213,8 +211,8 @@ export default async function ClientDetailPage({
|
|||||||
<Card className="">
|
<Card className="">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-blue-subtle rounded-lg p-2">
|
||||||
<DollarSign className="text-primary h-5 w-5" />
|
<DollarSign className="text-icon-blue h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span>Recent Invoices</span>
|
<span>Recent Invoices</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -224,48 +222,32 @@ export default async function ClientDetailPage({
|
|||||||
{client.invoices.slice(0, 3).map((invoice) => (
|
{client.invoices.slice(0, 3).map((invoice) => (
|
||||||
<div
|
<div
|
||||||
key={invoice.id}
|
key={invoice.id}
|
||||||
className="card-secondary hover:bg-muted/50 border p-3 transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div>
|
||||||
<div className="min-w-0">
|
<p className="text-foreground font-medium">
|
||||||
<p className="text-foreground font-medium break-words">
|
{invoice.invoiceNumber}
|
||||||
{invoice.invoiceNumber}
|
</p>
|
||||||
</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
<p className="text-muted-foreground text-sm">
|
{formatDate(invoice.issueDate)}
|
||||||
{formatDate(invoice.issueDate)}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<div className="text-right">
|
||||||
<div className="flex flex-shrink-0 items-center gap-2 self-start sm:flex-col sm:items-end sm:gap-1">
|
<p className="text-foreground font-semibold">
|
||||||
<p className="text-foreground font-semibold">
|
{formatCurrency(invoice.totalAmount)}
|
||||||
{formatCurrency(invoice.totalAmount)}
|
</p>
|
||||||
</p>
|
<Badge
|
||||||
<Badge
|
variant={
|
||||||
variant={
|
invoice.status === "paid"
|
||||||
getEffectiveInvoiceStatus(
|
? "default"
|
||||||
invoice.status as StoredInvoiceStatus,
|
: invoice.status === "sent"
|
||||||
invoice.dueDate,
|
? "secondary"
|
||||||
) === "paid"
|
: "outline"
|
||||||
? "default"
|
}
|
||||||
: getEffectiveInvoiceStatus(
|
className="text-xs"
|
||||||
invoice.status as StoredInvoiceStatus,
|
>
|
||||||
invoice.dueDate,
|
{invoice.status}
|
||||||
) === "sent"
|
</Badge>
|
||||||
? "secondary"
|
|
||||||
: getEffectiveInvoiceStatus(
|
|
||||||
invoice.status as StoredInvoiceStatus,
|
|
||||||
invoice.dueDate,
|
|
||||||
) === "overdue"
|
|
||||||
? "destructive"
|
|
||||||
: "outline"
|
|
||||||
}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{getEffectiveInvoiceStatus(
|
|
||||||
invoice.status as StoredInvoiceStatus,
|
|
||||||
invoice.dueDate,
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -90,13 +90,13 @@ export function ClientsDataTable({
|
|||||||
const client = row.original;
|
const client = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 hidden p-2 sm:flex">
|
<div className="bg-status-info-muted hidden rounded-lg p-2 sm:flex">
|
||||||
<UserPlus className="text-primary h-4 w-4" />
|
<UserPlus className="text-status-info h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium">{client.name}</p>
|
<p className="truncate font-medium">{client.name}</p>
|
||||||
<p className="text-muted-foreground truncate text-sm">
|
<p className="text-muted-foreground truncate text-sm">
|
||||||
{client.email ?? "—"}
|
{client.email || "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +108,7 @@ export function ClientsDataTable({
|
|||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Phone" />
|
<DataTableColumnHeader column={column} title="Phone" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => row.original.phone ?? "—",
|
cell: ({ row }) => row.original.phone || "—",
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "hidden md:table-cell",
|
headerClassName: "hidden md:table-cell",
|
||||||
cellClassName: "hidden md:table-cell",
|
cellClassName: "hidden md:table-cell",
|
||||||
@@ -148,9 +148,9 @@ export function ClientsDataTable({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
data-action-button="true"
|
data-action-button="true"
|
||||||
>
|
>
|
||||||
@@ -192,8 +192,7 @@ export function ClientsDataTable({
|
|||||||
<DialogTitle>Are you sure?</DialogTitle>
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
client "{clientToDelete?.name}" and remove all
|
client "{clientToDelete?.name}" and remove all associated data.
|
||||||
associated data.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { Plus } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { HydrateClient } from "~/trpc/server";
|
import { HydrateClient } from "~/trpc/server";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
import { ClientsTable } from "./_components/clients-table";
|
import { ClientsTable } from "./_components/clients-table";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { PageContent, PageSection } from "~/components/layout/page-layout";
|
||||||
|
|
||||||
export default async function ClientsPage() {
|
export default async function ClientsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Clients"
|
title="Clients"
|
||||||
description="Manage your clients and their information."
|
description="Manage your clients and their information."
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
<Button asChild className="btn-brand-primary shadow-md">
|
||||||
<Link href="/dashboard/clients/new">
|
<Link href="/dashboard/clients/new">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
<span>Add Client</span>
|
<span>Add Client</span>
|
||||||
@@ -24,6 +25,6 @@ export default async function ClientsPage() {
|
|||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<ClientsTable />
|
<ClientsTable />
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import { Label } from "~/components/ui/label";
|
|
||||||
import { Checkbox } from "~/components/ui/checkbox";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "~/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "~/components/ui/select";
|
|
||||||
import { DatePicker } from "~/components/ui/date-picker";
|
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
|
|
||||||
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
|
||||||
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
|
||||||
|
|
||||||
interface ExpenseFormData {
|
|
||||||
date: Date;
|
|
||||||
description: string;
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
category: string;
|
|
||||||
billable: boolean;
|
|
||||||
reimbursable: boolean;
|
|
||||||
taxDeductible: boolean;
|
|
||||||
notes: string;
|
|
||||||
clientId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultForm: ExpenseFormData = {
|
|
||||||
date: new Date(),
|
|
||||||
description: "",
|
|
||||||
amount: 0,
|
|
||||||
currency: "USD",
|
|
||||||
category: "",
|
|
||||||
billable: false,
|
|
||||||
reimbursable: false,
|
|
||||||
taxDeductible: false,
|
|
||||||
notes: "",
|
|
||||||
clientId: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ExpensesPage() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [editId, setEditId] = useState<string | null>(null);
|
|
||||||
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
|
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const { data: expenses = [], isLoading } = api.expenses.getAll.useQuery();
|
|
||||||
const { data: clients = [] } = api.clients.getAll.useQuery();
|
|
||||||
|
|
||||||
const create = api.expenses.create.useMutation({
|
|
||||||
onSuccess: () => { toast.success("Expense added"); void utils.expenses.getAll.invalidate(); setOpen(false); setForm(defaultForm); },
|
|
||||||
onError: (e) => toast.error(e.message),
|
|
||||||
});
|
|
||||||
const update = api.expenses.update.useMutation({
|
|
||||||
onSuccess: () => { toast.success("Expense updated"); void utils.expenses.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); },
|
|
||||||
onError: (e) => toast.error(e.message),
|
|
||||||
});
|
|
||||||
const del = api.expenses.delete.useMutation({
|
|
||||||
onSuccess: () => { toast.success("Expense deleted"); void utils.expenses.getAll.invalidate(); setDeleteId(null); },
|
|
||||||
onError: (e) => toast.error(e.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleOpen = () => { setEditId(null); setForm(defaultForm); setOpen(true); };
|
|
||||||
const handleEdit = (expense: typeof expenses[0]) => {
|
|
||||||
setEditId(expense.id);
|
|
||||||
setForm({
|
|
||||||
date: new Date(expense.date),
|
|
||||||
description: expense.description,
|
|
||||||
amount: expense.amount,
|
|
||||||
currency: expense.currency,
|
|
||||||
category: expense.category ?? "",
|
|
||||||
billable: expense.billable,
|
|
||||||
reimbursable: expense.reimbursable,
|
|
||||||
taxDeductible: expense.taxDeductible ?? false,
|
|
||||||
notes: expense.notes ?? "",
|
|
||||||
clientId: expense.clientId ?? "",
|
|
||||||
});
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (!form.description.trim()) { toast.error("Description is required"); return; }
|
|
||||||
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; }
|
|
||||||
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined, taxDeductible: form.taxDeductible };
|
|
||||||
if (editId) update.mutate({ id: editId, ...payload });
|
|
||||||
else create.mutate(payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
|
||||||
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0);
|
|
||||||
const deductibleTotal = expenses.filter((e) => e.taxDeductible).reduce((s, e) => s + e.amount, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-enter space-y-6 pb-6">
|
|
||||||
<PageHeader title="Expenses" description="Track billable and non-billable expenses" variant="gradient">
|
|
||||||
<Button onClick={handleOpen} variant="default" className="hover-lift shadow-md">
|
|
||||||
<Plus className="mr-2 h-5 w-5" /> Add Expense
|
|
||||||
</Button>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
{/* Summary cards */}
|
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
|
|
||||||
<p className="mt-1 text-2xl font-bold">{formatCurrency(totalExpenses)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Billable</p>
|
|
||||||
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Deductible</p>
|
|
||||||
<p className="mt-1 text-2xl font-bold text-green-600">{formatCurrency(deductibleTotal)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
|
|
||||||
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expenses list */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Receipt className="h-5 w-5" /> All Expenses
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="p-6 text-center text-sm text-muted-foreground">Loading…</div>
|
|
||||||
) : expenses.length === 0 ? (
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
|
||||||
<p className="text-muted-foreground text-sm">No expenses yet. Add your first expense.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y">
|
|
||||||
{expenses.map((expense) => (
|
|
||||||
<div key={expense.id} className="flex items-start justify-between gap-3 p-4">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<p className="font-medium">{expense.description}</p>
|
|
||||||
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
|
|
||||||
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>}
|
|
||||||
{expense.taxDeductible && <Badge variant="outline" className="text-xs text-green-600 border-green-300">Tax Deductible</Badge>}
|
|
||||||
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>}
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
||||||
{new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(expense.date))}
|
|
||||||
{expense.client ? ` · ${expense.client.name}` : ""}
|
|
||||||
</p>
|
|
||||||
{expense.notes && <p className="text-muted-foreground mt-1 text-xs">{expense.notes}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-2">
|
|
||||||
<p className="font-semibold">{formatCurrency(expense.amount, expense.currency)}</p>
|
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(expense)}><Pencil className="h-3.5 w-3.5" /></Button>
|
|
||||||
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(expense.id)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Add/Edit dialog */}
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Description *</Label>
|
|
||||||
<Input value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} placeholder="e.g. Laptop charger" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Amount *</Label>
|
|
||||||
<NumberInput value={form.amount} onChange={(v) => setForm((p) => ({ ...p, amount: v }))} min={0} step={0.01} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Currency</Label>
|
|
||||||
<Select value={form.currency} onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}>
|
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>{SUPPORTED_CURRENCIES.map((c) => <SelectItem key={c.code} value={c.code}>{c.code}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Date</Label>
|
|
||||||
<DatePicker date={form.date} onDateChange={(d) => setForm((p) => ({ ...p, date: d ?? new Date() }))} className="w-full" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Category</Label>
|
|
||||||
<Select value={form.category || "none"} onValueChange={(v) => setForm((p) => ({ ...p, category: v === "none" ? "" : v }))}>
|
|
||||||
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">None</SelectItem>
|
|
||||||
{EXPENSE_CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Client (optional)</Label>
|
|
||||||
<Select value={form.clientId || "none"} onValueChange={(v) => setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))}>
|
|
||||||
<SelectTrigger><SelectValue placeholder="No client" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">No client</SelectItem>
|
|
||||||
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-6">
|
|
||||||
<label className="flex cursor-pointer items-center gap-2">
|
|
||||||
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
|
|
||||||
<span className="text-sm">Billable</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex cursor-pointer items-center gap-2">
|
|
||||||
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
|
|
||||||
<span className="text-sm">Reimbursable</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex cursor-pointer items-center gap-2">
|
|
||||||
<Checkbox checked={form.taxDeductible} onCheckedChange={(v) => setForm((p) => ({ ...p, taxDeductible: !!v }))} />
|
|
||||||
<span className="text-sm">Tax Deductible</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Notes (optional)</Label>
|
|
||||||
<Input value={form.notes} onChange={(e) => setForm((p) => ({ ...p, notes: e.target.value }))} placeholder="Additional details…" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
|
||||||
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
|
|
||||||
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Add Expense"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete dialog */}
|
|
||||||
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Expense</DialogTitle>
|
|
||||||
<DialogDescription>This action cannot be undone.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button>
|
|
||||||
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}>
|
|
||||||
{del.isPending ? "Deleting…" : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +1,44 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
|
|
||||||
export function InvoiceDetailsSkeleton() {
|
export function InvoiceDetailsSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-24">
|
<div className="space-y-6 pb-24">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<PageHeader
|
<div className="flex items-center justify-between">
|
||||||
title="Loading..."
|
<div>
|
||||||
description="View and manage invoice information"
|
<Skeleton className="bg-muted/30 h-8 w-48 sm:h-9 sm:w-64" />
|
||||||
variant="gradient"
|
<Skeleton className="bg-muted/30 mt-1 h-4 w-40 sm:w-48" />
|
||||||
>
|
</div>
|
||||||
<Skeleton className="h-10 w-10 sm:w-32" />
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="h-10 w-24" />
|
<Skeleton className="bg-muted/30 h-8 w-20 sm:h-9 sm:w-24" />
|
||||||
</PageHeader>
|
<Skeleton className="bg-muted/30 h-8 w-16 sm:h-9 sm:w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
{/* Invoice Header Skeleton */}
|
{/* Invoice Header Skeleton */}
|
||||||
<Card>
|
<Card className="card-primary">
|
||||||
<CardContent className="p-4 sm:p-6">
|
<CardContent className="p-4 sm:p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
<div className="flex items-start justify-between gap-6">
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
<Skeleton className="h-8 w-48" />
|
<Skeleton className="bg-muted/30 h-6 w-40 sm:h-8 sm:w-48" />
|
||||||
<Skeleton className="h-6 w-24 rounded-full" />
|
<Skeleton className="bg-muted/30 h-5 w-16 sm:h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm sm:space-y-0">
|
<div className="space-y-1 sm:space-y-0">
|
||||||
<div className="flex gap-2">
|
<Skeleton className="bg-muted/30 h-3 w-32 sm:h-4 sm:w-40" />
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="bg-muted/30 h-3 w-28 sm:hidden sm:h-4 sm:w-36" />
|
||||||
<Skeleton className="hidden h-4 w-32 sm:block" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-left sm:text-right">
|
<div className="flex-shrink-0 text-right">
|
||||||
<Skeleton className="mb-1 h-4 w-24 sm:ml-auto" />
|
<Skeleton className="bg-muted/30 h-3 w-20 sm:h-4" />
|
||||||
<Skeleton className="h-9 w-32 sm:ml-auto" />
|
<Skeleton className="bg-muted/30 mt-1 h-6 w-24 sm:h-8 sm:w-28" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,126 +47,105 @@ export function InvoiceDetailsSkeleton() {
|
|||||||
|
|
||||||
{/* Client & Business Info */}
|
{/* Client & Business Info */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{/* Client Skeleton */}
|
{Array.from({ length: 2 }).map((_, i) => (
|
||||||
<Card>
|
<Card key={i} className="card-primary">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="h-5 w-5 rounded-full" />
|
<Skeleton className="bg-muted/30 h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
<Skeleton className="h-5 w-16" />
|
<Skeleton className="bg-muted/30 h-5 w-16 sm:h-6" />
|
||||||
</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>
|
||||||
<div className="flex items-center gap-3">
|
</CardHeader>
|
||||||
<Skeleton className="h-8 w-8 rounded-md" />
|
<CardContent className="space-y-4">
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="bg-muted/30 h-5 w-32 sm:h-6" />
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
</CardContent>
|
||||||
<Skeleton className="h-8 w-8 rounded-md" />
|
</Card>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Invoice Items Skeleton */}
|
{/* Invoice Items Skeleton */}
|
||||||
<Card>
|
<Card className="card-primary">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="h-5 w-5 rounded-full" />
|
<Skeleton className="bg-muted/30 h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
<Skeleton className="h-5 w-32" />
|
<Skeleton className="bg-muted/30 h-5 w-28 sm:h-6" />
|
||||||
</CardTitle>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Item Rows */}
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<Card key={i} className="bg-secondary/50 border-0">
|
<div key={i} className="space-y-3 rounded-lg border p-4">
|
||||||
<CardContent className="p-3">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="space-y-3">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
<Skeleton className="bg-muted/30 mb-2 h-4 w-full sm:h-5 sm:w-3/4" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="space-y-1 sm:space-y-0">
|
||||||
<Skeleton className="mb-2 h-5 w-3/4" />
|
<Skeleton className="bg-muted/30 h-3 w-20 sm:h-4 sm:w-24" />
|
||||||
<div className="flex gap-4">
|
<Skeleton className="bg-muted/30 h-3 w-16 sm:hidden sm:h-4 sm:w-20" />
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="bg-muted/30 h-3 w-24 sm:hidden sm:h-4 sm:w-28" />
|
||||||
<Skeleton className="h-4 w-16" />
|
|
||||||
<Skeleton className="h-4 w-20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-6 w-24" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div className="flex-shrink-0 text-right">
|
||||||
</Card>
|
<Skeleton className="bg-muted/30 h-4 w-16 sm:h-5 sm:w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Totals */}
|
{/* Totals */}
|
||||||
<div className="bg-secondary rounded-lg p-4">
|
<div className="bg-muted/30 rounded-lg p-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<Skeleton className="h-4 w-20" />
|
<Skeleton className="bg-muted/30 h-4 w-16" />
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="bg-muted/30 h-4 w-20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="bg-muted/30 h-4 w-20" />
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="bg-muted/30 h-4 w-16" />
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<Skeleton className="h-6 w-16" />
|
<Skeleton className="bg-muted/30 h-5 w-12" />
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="bg-muted/30 h-5 w-24" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="lg:sticky lg:top-6">
|
<Card className="card-primary sticky top-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="h-5 w-5 rounded-full" />
|
<Skeleton className="bg-muted/30 h-5 w-5" />
|
||||||
<Skeleton className="h-5 w-24" />
|
<Skeleton className="bg-muted/30 h-6 w-16" />
|
||||||
</CardTitle>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<Skeleton className="h-10 w-full" />
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<Skeleton className="h-10 w-full" />
|
<Skeleton key={i} className="bg-muted/30 h-10 w-full" />
|
||||||
<Skeleton className="h-10 w-full" />
|
))}
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,32 +40,13 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
accessorKey: "date",
|
accessorKey: "date",
|
||||||
header: "Date",
|
header: "Date",
|
||||||
cell: ({ row }) => formatDate(row.getValue("date")),
|
cell: ({ row }) => formatDate(row.getValue("date")),
|
||||||
meta: {
|
|
||||||
headerClassName: "hidden sm:table-cell",
|
|
||||||
cellClassName: "hidden sm:table-cell",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "description",
|
accessorKey: "description",
|
||||||
header: "Description",
|
header: "Description",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const item = row.original;
|
<div className="font-medium">{row.getValue("description")}</div>
|
||||||
return (
|
),
|
||||||
<>
|
|
||||||
{/* Desktop: plain description */}
|
|
||||||
<div className="hidden font-medium sm:block">
|
|
||||||
{item.description}
|
|
||||||
</div>
|
|
||||||
{/* Mobile: description + date + hours @ rate stacked */}
|
|
||||||
<div className="sm:hidden">
|
|
||||||
<p className="font-medium">{item.description}</p>
|
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
||||||
{formatDate(item.date)} · {item.hours}h @ {formatCurrency(item.rate)}/hr
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "hours",
|
accessorKey: "hours",
|
||||||
@@ -73,10 +54,6 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-right">{row.getValue("hours")}</div>
|
<div className="text-right">{row.getValue("hours")}</div>
|
||||||
),
|
),
|
||||||
meta: {
|
|
||||||
headerClassName: "hidden sm:table-cell",
|
|
||||||
cellClassName: "hidden sm:table-cell",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "rate",
|
accessorKey: "rate",
|
||||||
@@ -84,16 +61,12 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
|
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
|
||||||
),
|
),
|
||||||
meta: {
|
|
||||||
headerClassName: "hidden sm:table-cell",
|
|
||||||
cellClassName: "hidden sm:table-cell",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "amount",
|
accessorKey: "amount",
|
||||||
header: "Amount",
|
header: "Amount",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-primary text-right font-medium">
|
<div className="text-icon-emerald text-right font-medium">
|
||||||
{formatCurrency(row.getValue("amount"))}
|
{formatCurrency(row.getValue("amount"))}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Download, Loader2 } from "lucide-react";
|
|||||||
|
|
||||||
interface PDFDownloadButtonProps {
|
interface PDFDownloadButtonProps {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
variant?: "default" | "outline" | "ghost" | "icon" | "secondary";
|
variant?: "default" | "outline" | "ghost" | "icon";
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,9 +25,6 @@ export function PDFDownloadButton({
|
|||||||
{ id: invoiceId },
|
{ id: invoiceId },
|
||||||
{ enabled: false },
|
{ enabled: false },
|
||||||
);
|
);
|
||||||
const { data: platformTheme } = api.settings.getTheme.useQuery(undefined, {
|
|
||||||
staleTime: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDownloadPDF = async () => {
|
const handleDownloadPDF = async () => {
|
||||||
if (isGenerating) return;
|
if (isGenerating) return;
|
||||||
@@ -42,29 +39,7 @@ export function PDFDownloadButton({
|
|||||||
throw new Error("Invoice not found");
|
throw new Error("Invoice not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map invoice to PDF format with currency support
|
await generateInvoicePDF(invoiceData);
|
||||||
const pdfData = {
|
|
||||||
invoiceNumber: invoiceData.invoiceNumber,
|
|
||||||
invoicePrefix: invoiceData.invoicePrefix,
|
|
||||||
issueDate: new Date(invoiceData.issueDate),
|
|
||||||
dueDate: new Date(invoiceData.dueDate),
|
|
||||||
status: invoiceData.status,
|
|
||||||
totalAmount: invoiceData.totalAmount,
|
|
||||||
taxRate: invoiceData.taxRate,
|
|
||||||
currency: invoiceData.currency ?? "USD",
|
|
||||||
notes: invoiceData.notes,
|
|
||||||
business: invoiceData.business,
|
|
||||||
client: invoiceData.client,
|
|
||||||
items: invoiceData.items,
|
|
||||||
};
|
|
||||||
|
|
||||||
await generateInvoicePDF(pdfData, {
|
|
||||||
pdfTemplate: platformTheme?.pdfTemplate,
|
|
||||||
pdfAccentColor: platformTheme?.pdfAccentColor,
|
|
||||||
pdfFooterText: platformTheme?.pdfFooterText,
|
|
||||||
pdfShowLogo: platformTheme?.pdfShowLogo,
|
|
||||||
pdfShowPageNumbers: platformTheme?.pdfShowPageNumbers,
|
|
||||||
});
|
|
||||||
toast.success("PDF downloaded successfully");
|
toast.success("PDF downloaded successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("PDF generation error:", error);
|
console.error("PDF generation error:", error);
|
||||||
@@ -102,12 +77,12 @@ export function PDFDownloadButton({
|
|||||||
>
|
>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
<span>Generating PDF...</span>
|
<span>Generating PDF...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Download className="mr-2 h-5 w-5" />
|
<Download className="h-5 w-5" />
|
||||||
<span>Download PDF</span>
|
<span>Download PDF</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,68 +4,27 @@ import { useState } from "react";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||||
import { Send, Loader2 } from "lucide-react";
|
import { Send, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
interface SendInvoiceButtonProps {
|
interface SendInvoiceButtonProps {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
variant?: "default" | "outline" | "ghost" | "icon";
|
variant?: "default" | "outline" | "ghost" | "icon";
|
||||||
className?: string;
|
className?: string;
|
||||||
showResend?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SendInvoiceButton({
|
export function SendInvoiceButton({
|
||||||
invoiceId,
|
invoiceId,
|
||||||
variant = "outline",
|
variant = "outline",
|
||||||
className,
|
className,
|
||||||
showResend = false,
|
|
||||||
}: SendInvoiceButtonProps) {
|
}: SendInvoiceButtonProps) {
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
|
||||||
// Get utils for cache invalidation
|
// Fetch invoice data when sending is triggered
|
||||||
const utils = api.useUtils();
|
const { refetch: fetchInvoice } = api.invoices.getById.useQuery(
|
||||||
|
{ id: invoiceId },
|
||||||
// Use the new email API mutation
|
{ enabled: false },
|
||||||
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 () => {
|
const handleSendInvoice = async () => {
|
||||||
if (isSending) return;
|
if (isSending) return;
|
||||||
@@ -73,12 +32,88 @@ export function SendInvoiceButton({
|
|||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendInvoiceMutation.mutateAsync({
|
// Fetch fresh invoice data
|
||||||
invoiceId,
|
const { data: invoice } = await fetchInvoice();
|
||||||
});
|
|
||||||
|
if (!invoice) {
|
||||||
|
throw new Error("Invoice not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate PDF blob for potential attachment
|
||||||
|
const pdfBlob = await generateInvoicePDFBlob(invoice);
|
||||||
|
|
||||||
|
// Create a temporary download URL for the PDF
|
||||||
|
const pdfUrl = URL.createObjectURL(pdfBlob);
|
||||||
|
|
||||||
|
// Format currency
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(new Date(date));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate days until due
|
||||||
|
const today = new Date();
|
||||||
|
const dueDate = new Date(invoice.dueDate);
|
||||||
|
const daysUntilDue = Math.ceil(
|
||||||
|
(dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create professional email template
|
||||||
|
const subject = `Invoice ${invoice.invoiceNumber} - ${formatCurrency(invoice.totalAmount)}`;
|
||||||
|
|
||||||
|
const body = `Dear ${invoice.client.name},
|
||||||
|
|
||||||
|
I hope this email finds you well. Please find attached invoice ${invoice.invoiceNumber} for the services provided.
|
||||||
|
|
||||||
|
Invoice Details:
|
||||||
|
• Invoice Number: ${invoice.invoiceNumber}
|
||||||
|
• Issue Date: ${formatDate(invoice.issueDate)}
|
||||||
|
• Due Date: ${formatDate(invoice.dueDate)}
|
||||||
|
• Amount Due: ${formatCurrency(invoice.totalAmount)}
|
||||||
|
${daysUntilDue > 0 ? `• Payment Due: In ${daysUntilDue} days` : daysUntilDue === 0 ? `• Payment Due: Today` : `• Status: ${Math.abs(daysUntilDue)} days overdue`}
|
||||||
|
|
||||||
|
${invoice.notes ? `\nAdditional Notes:\n${invoice.notes}\n` : ""}
|
||||||
|
Please review the attached invoice and remit payment by the due date. If you have any questions or concerns regarding this invoice, please don't hesitate to contact me.
|
||||||
|
|
||||||
|
Thank you for your business!
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
${invoice.business?.name ?? "Your Business Name"}
|
||||||
|
${invoice.business?.email ? `\n${invoice.business.email}` : ""}
|
||||||
|
${invoice.business?.phone ? `\n${invoice.business.phone}` : ""}`;
|
||||||
|
|
||||||
|
// Create mailto link
|
||||||
|
const mailtoLink = `mailto:${invoice.client.email ?? ""}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||||
|
|
||||||
|
// Create a temporary link element to trigger mailto
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = mailtoLink;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
// Clean up the PDF URL object
|
||||||
|
URL.revokeObjectURL(pdfUrl);
|
||||||
|
|
||||||
|
toast.success("Email client opened with invoice details");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error is already handled by the mutation's onError
|
|
||||||
console.error("Send invoice error:", error);
|
console.error("Send invoice error:", error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to prepare invoice email",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
}
|
}
|
||||||
@@ -114,12 +149,12 @@ export function SendInvoiceButton({
|
|||||||
{isSending ? (
|
{isSending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<span>Sending Email...</span>
|
<span>Preparing Email...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Send className="mr-2 h-4 w-4" />
|
<Send className="mr-2 h-4 w-4" />
|
||||||
<span>{showResend ? "Resend Invoice" : "Send Invoice"}</span>
|
<span>Send Invoice</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { InvoiceView } from "~/components/data/invoice-view";
|
import { InvoiceView } from "~/components/data/invoice-view";
|
||||||
import InvoiceForm from "~/components/forms/invoice-form";
|
import { InvoiceForm } from "~/components/forms/invoice-form";
|
||||||
|
|
||||||
interface UnifiedInvoicePageProps {
|
interface UnifiedInvoicePageProps {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import InvoiceForm from "~/components/forms/invoice-form";
|
import { InvoiceForm } from "~/components/forms/invoice-form";
|
||||||
|
|
||||||
export default function InvoiceFormPage() {
|
export default function EditInvoicePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const id = params.id as string;
|
const invoiceId = params.id as string;
|
||||||
|
|
||||||
// Pass the actual id, let the form component handle the logic
|
return <InvoiceForm invoiceId={invoiceId} />;
|
||||||
return <InvoiceForm invoiceId={id} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,34 @@
|
|||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
import { DollarSign, Edit, Loader2, Trash2 } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound, useParams, useRouter } from "next/navigation";
|
import { api, HydrateClient } from "~/trpc/server";
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import {
|
import { Button } from "~/components/ui/button";
|
||||||
Dialog,
|
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "~/components/ui/dialog";
|
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import {
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
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 { PDFDownloadButton } from "./_components/pdf-download-button";
|
||||||
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
|
import { SendInvoiceButton } from "./_components/send-invoice-button";
|
||||||
|
import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
|
||||||
Building,
|
Building,
|
||||||
Check,
|
Edit,
|
||||||
FileText,
|
FileText,
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Phone,
|
Phone,
|
||||||
User,
|
User,
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
interface InvoicePageProps {
|
||||||
const router = useRouter();
|
params: Promise<{ id: string }>;
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
}
|
||||||
|
|
||||||
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
|
async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||||
id: invoiceId,
|
const invoice = await api.invoices.getById({ id: invoiceId });
|
||||||
});
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const deleteInvoice = api.invoices.delete.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Invoice deleted successfully");
|
|
||||||
router.push("/dashboard/invoices");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message ?? "Failed to delete invoice");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateStatus = api.invoices.updateStatus.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast.success(data.message);
|
|
||||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message ?? "Failed to update invoice status");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
setDeleteDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarkAsPaid = () => {
|
|
||||||
updateStatus.mutate({
|
|
||||||
id: invoiceId,
|
|
||||||
status: "paid" as StoredInvoiceStatus,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDelete = () => {
|
|
||||||
deleteInvoice.mutate({ id: invoiceId });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <InvoiceDetailsSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
notFound();
|
notFound();
|
||||||
@@ -109,34 +52,30 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||||
const total = subtotal + taxAmount;
|
const total = subtotal + taxAmount;
|
||||||
const effectiveStatus = getEffectiveInvoiceStatus(
|
const isOverdue =
|
||||||
invoice.status as StoredInvoiceStatus,
|
new Date(invoice.dueDate) < new Date() && invoice.status !== "paid";
|
||||||
invoice.dueDate,
|
|
||||||
);
|
|
||||||
const isOverdue = isInvoiceOverdue(
|
|
||||||
invoice.status as StoredInvoiceStatus,
|
|
||||||
invoice.dueDate,
|
|
||||||
);
|
|
||||||
|
|
||||||
const getStatusType = (): StatusType => {
|
const getStatusType = (): StatusType => {
|
||||||
return effectiveStatus as 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 (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-24">
|
<div className="space-y-6 pb-24">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Invoice Details"
|
title="Invoice Details"
|
||||||
description="View and manage invoice information"
|
description="View and manage invoice information"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<PDFDownloadButton
|
<PDFDownloadButton invoiceId={invoice.id} variant="outline" />
|
||||||
invoiceId={invoice.id}
|
<Button asChild variant="default">
|
||||||
variant="outline"
|
|
||||||
className="hover-lift"
|
|
||||||
/>
|
|
||||||
<Button asChild variant="default" className="hover-lift">
|
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
<Edit className="mr-2 h-5 w-5" />
|
<Edit className="h-5 w-5" />
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -147,13 +86,13 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
{/* Invoice Header */}
|
{/* Invoice Header */}
|
||||||
<Card>
|
<Card className="card-primary">
|
||||||
<CardContent className="p-4 sm:p-6">
|
<CardContent className="p-4 sm:p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
<div className="flex items-start justify-between gap-6">
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
<h2 className="text-foreground text-2xl font-bold break-words">
|
<h2 className="text-foreground truncate text-2xl font-bold">
|
||||||
{invoice.invoiceNumber}
|
{invoice.invoiceNumber}
|
||||||
</h2>
|
</h2>
|
||||||
<StatusBadge status={getStatusType()} />
|
<StatusBadge status={getStatusType()} />
|
||||||
@@ -167,7 +106,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-left sm:text-right">
|
<div className="flex-shrink-0 text-right">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Total Amount
|
Total Amount
|
||||||
</p>
|
</p>
|
||||||
@@ -182,7 +121,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{/* Overdue Alert */}
|
{/* Overdue Alert */}
|
||||||
{isOverdue && (
|
{isOverdue && (
|
||||||
<Card className="border-destructive/20 bg-destructive/5">
|
<Card className="border-destructive/20 bg-destructive/5 card-secondary">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-destructive flex items-center gap-3">
|
<div className="text-destructive flex items-center gap-3">
|
||||||
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
||||||
@@ -205,7 +144,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
{/* Client & Business Info */}
|
{/* Client & Business Info */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{/* Client Information */}
|
{/* Client Information */}
|
||||||
<Card>
|
<Card className="card-primary">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<User className="h-5 w-5" />
|
<User className="h-5 w-5" />
|
||||||
@@ -222,7 +161,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{invoice.client.email && (
|
{invoice.client.email && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
<Mail className="text-primary h-4 w-4" />
|
<Mail className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm break-all">
|
<span className="text-sm break-all">
|
||||||
@@ -233,7 +172,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{invoice.client.phone && (
|
{invoice.client.phone && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
<Phone className="text-primary h-4 w-4" />
|
<Phone className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm">{invoice.client.phone}</span>
|
<span className="text-sm">{invoice.client.phone}</span>
|
||||||
@@ -242,7 +181,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{(invoice.client.addressLine1 ?? invoice.client.city) && (
|
{(invoice.client.addressLine1 ?? invoice.client.city) && (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
<MapPin className="text-primary h-4 w-4" />
|
<MapPin className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
@@ -277,7 +216,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{/* Business Information */}
|
{/* Business Information */}
|
||||||
{invoice.business && (
|
{invoice.business && (
|
||||||
<Card>
|
<Card className="card-primary">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Building className="h-5 w-5" />
|
<Building className="h-5 w-5" />
|
||||||
@@ -294,7 +233,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{invoice.business.email && (
|
{invoice.business.email && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
<Mail className="text-primary h-4 w-4" />
|
<Mail className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm break-all">
|
<span className="text-sm break-all">
|
||||||
@@ -305,7 +244,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{invoice.business.phone && (
|
{invoice.business.phone && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-primary/10 rounded-lg p-2">
|
||||||
<Phone className="text-primary h-4 w-4" />
|
<Phone className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
@@ -320,7 +259,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invoice Items */}
|
{/* Invoice Items */}
|
||||||
<Card>
|
<Card className="card-primary">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
@@ -328,52 +267,48 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{invoice.items.map((item, _index) => (
|
{invoice.items.map((item) => (
|
||||||
<Card key={item.id} className="invoice-item bg-secondary">
|
<Card key={item.id} className="card-secondary">
|
||||||
<CardContent className="p-3">
|
<CardContent className="py-2">
|
||||||
<div className="space-y-3">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="min-w-0 flex-1">
|
<p className="text-foreground mb-2 text-base font-medium">
|
||||||
<p className="text-foreground mb-2 text-base font-medium break-words">
|
{item.description}
|
||||||
{item.description}
|
</p>
|
||||||
</p>
|
<div className="text-muted-foreground text-sm">
|
||||||
<div className="text-muted-foreground text-sm">
|
<span className="inline whitespace-nowrap">
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
{formatDate(item.date).replace(/ /g, "\u00A0")}
|
||||||
<span className="whitespace-nowrap">
|
</span>
|
||||||
{formatDate(item.date).replace(/ /g, "\u00A0")}
|
<span className="inline whitespace-nowrap before:mx-2 before:content-['_|_']">
|
||||||
</span>
|
{item.hours.toString().replace(/ /g, "\u00A0")}
|
||||||
<span className="whitespace-nowrap">
|
hours
|
||||||
{item.hours.toString().replace(/ /g, "\u00A0")}
|
</span>
|
||||||
hours
|
<span className="inline whitespace-nowrap before:mx-2 before:content-['_|_']">
|
||||||
</span>
|
@ ${item.rate}/hr
|
||||||
<span className="whitespace-nowrap">
|
</span>
|
||||||
@ ${item.rate}/hr
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-shrink-0 self-start">
|
|
||||||
<p className="text-primary text-lg font-semibold">
|
|
||||||
{formatCurrency(item.amount)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-right">
|
||||||
|
<p className="text-primary text-lg font-semibold">
|
||||||
|
{formatCurrency(item.amount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Totals */}
|
{/* Totals */}
|
||||||
<div className="bg-secondary rounded-lg p-4">
|
<div className="bg-muted/30 rounded-lg p-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Subtotal:</span>
|
<span className="text-muted-foreground">Subtotal:</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatCurrency(subtotal)}
|
{formatCurrency(subtotal)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{invoice.taxRate > 0 && (
|
{invoice.taxRate > 0 && (
|
||||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
Tax ({invoice.taxRate}%):
|
Tax ({invoice.taxRate}%):
|
||||||
</span>
|
</span>
|
||||||
@@ -383,7 +318,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-lg font-bold">
|
<div className="flex justify-between text-lg font-bold">
|
||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<span className="text-primary">
|
<span className="text-primary">
|
||||||
{formatCurrency(total)}
|
{formatCurrency(total)}
|
||||||
@@ -396,7 +331,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{invoice.notes && (
|
{invoice.notes && (
|
||||||
<Card>
|
<Card className="card-primary">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Notes</CardTitle>
|
<CardTitle>Notes</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -411,7 +346,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="lg:sticky lg:top-6">
|
<Card className="card-primary sticky top-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Check className="h-5 w-5" />
|
<Check className="h-5 w-5" />
|
||||||
@@ -419,7 +354,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<Button asChild variant="secondary" className="w-full">
|
<Button asChild variant="outline" className="w-full">
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit Invoice
|
Edit Invoice
|
||||||
@@ -427,117 +362,28 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{invoice.items && invoice.client && (
|
{invoice.items && invoice.client && (
|
||||||
<PDFDownloadButton
|
<PDFDownloadButton invoiceId={invoice.id} className="w-full" />
|
||||||
invoiceId={invoice.id}
|
|
||||||
className="w-full"
|
|
||||||
variant="secondary"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Send Invoice Button - Show for draft, sent, and overdue */}
|
{invoice.status === "draft" && (
|
||||||
{effectiveStatus === "draft" && (
|
<SendInvoiceButton invoiceId={invoice.id} className="w-full" />
|
||||||
<EnhancedSendInvoiceButton
|
|
||||||
invoiceId={invoice.id}
|
|
||||||
className="w-full"
|
|
||||||
variant="secondary"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(effectiveStatus === "sent" ||
|
|
||||||
effectiveStatus === "overdue") && (
|
|
||||||
<EnhancedSendInvoiceButton
|
|
||||||
invoiceId={invoice.id}
|
|
||||||
className="w-full"
|
|
||||||
showResend={true}
|
|
||||||
variant="secondary"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Manual Status Updates */}
|
|
||||||
{(effectiveStatus === "sent" ||
|
|
||||||
effectiveStatus === "overdue") && (
|
|
||||||
<Button
|
|
||||||
onClick={handleMarkAsPaid}
|
|
||||||
disabled={updateStatus.isPending}
|
|
||||||
variant="secondary"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{updateStatus.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<DollarSign className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Mark as Paid
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleteInvoice.isPending}
|
|
||||||
className="text-destructive hover:bg-destructive/10 w-full"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete Invoice
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Invoice</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete invoice{" "}
|
|
||||||
<strong>{invoice.invoiceNumber}</strong>? This action cannot be
|
|
||||||
undone and will permanently remove the invoice and all its data.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeleteDialogOpen(false)}
|
|
||||||
disabled={deleteInvoice.isPending}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={confirmDelete}
|
|
||||||
disabled={deleteInvoice.isPending}
|
|
||||||
>
|
|
||||||
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InvoiceViewPage() {
|
export default async function InvoicePage({ params }: InvoicePageProps) {
|
||||||
const params = useParams();
|
const { id } = await params;
|
||||||
const router = useRouter();
|
|
||||||
const id = params.id as string;
|
|
||||||
|
|
||||||
// Handle /invoices/new route - redirect to dedicated new page
|
return (
|
||||||
useEffect(() => {
|
<HydrateClient>
|
||||||
if (id === "new") {
|
<Suspense fallback={<InvoiceDetailsSkeleton />}>
|
||||||
router.replace("/dashboard/invoices/new");
|
<InvoiceContent invoiceId={id} />
|
||||||
}
|
</Suspense>
|
||||||
}, [id, router]);
|
</HydrateClient>
|
||||||
|
);
|
||||||
// Don't render anything if we're redirecting
|
|
||||||
if (id === "new") {
|
|
||||||
return (
|
|
||||||
<div className="flex h-96 items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <InvoiceViewContent invoiceId={id} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,595 +0,0 @@
|
|||||||
"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,35 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { ColumnDef, Row } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { Checkbox } from "~/components/ui/checkbox";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||||
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
|
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
|
||||||
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
||||||
import {
|
import { Eye, Edit } from "lucide-react";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "~/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "~/components/ui/dropdown-menu";
|
|
||||||
import { Eye, Edit, Trash2, FileText, CheckCircle, Send, ChevronDown } from "lucide-react";
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
|
||||||
import { formatCurrency } from "~/lib/currency";
|
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
|
||||||
|
|
||||||
|
// Type for invoice data
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
id: string;
|
id: string;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
@@ -40,16 +20,32 @@ interface Invoice {
|
|||||||
status: string;
|
status: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
currency: string;
|
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdById: string;
|
createdById: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
client?: { id: string; name: string; email: string | null; phone: string | null } | null;
|
client?: {
|
||||||
business?: { id: string; name: string; email: string | null; phone: string | null } | null;
|
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<{
|
items?: Array<{
|
||||||
id: string; invoiceId: string; date: Date; description: string;
|
id: string;
|
||||||
hours: number; rate: number; amount: number; position: number; createdAt: Date;
|
invoiceId: string;
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
hours: number;
|
||||||
|
rate: number;
|
||||||
|
amount: number;
|
||||||
|
position: number;
|
||||||
|
createdAt: Date;
|
||||||
}> | null;
|
}> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,129 +53,117 @@ interface InvoicesDataTableProps {
|
|||||||
invoices: Invoice[];
|
invoices: Invoice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusType = (invoice: Invoice): StatusType =>
|
const getStatusType = (invoice: Invoice): StatusType => {
|
||||||
getEffectiveInvoiceStatus(invoice.status as StoredInvoiceStatus, invoice.dueDate) as 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 formatDate = (date: Date) =>
|
const formatDate = (date: Date) => {
|
||||||
new Intl.DateTimeFormat("en-US", { month: "short", day: "2-digit", year: "numeric" }).format(new 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);
|
||||||
|
};
|
||||||
|
|
||||||
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
||||||
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
|
|
||||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
|
||||||
const [pendingBulkDelete, setPendingBulkDelete] = useState<Invoice[]>([]);
|
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const handleRowClick = (invoice: Invoice) => {
|
||||||
|
router.push(`/dashboard/invoices/${invoice.id}`);
|
||||||
const deleteInvoice = api.invoices.delete.useMutation({
|
};
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Invoice deleted");
|
|
||||||
void utils.invoices.getAll.invalidate();
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
setInvoiceToDelete(null);
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e.message ?? "Failed to delete invoice"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const bulkDelete = api.invoices.bulkDelete.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast.success(`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`);
|
|
||||||
void utils.invoices.getAll.invalidate();
|
|
||||||
setBulkDeleteDialogOpen(false);
|
|
||||||
setPendingBulkDelete([]);
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e.message ?? "Failed to delete invoices"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast.success(`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`);
|
|
||||||
void utils.invoices.getAll.invalidate();
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e.message ?? "Failed to update invoices"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns: ColumnDef<Invoice>[] = [
|
const columns: ColumnDef<Invoice>[] = [
|
||||||
{
|
|
||||||
id: "select",
|
|
||||||
header: ({ table }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
|
|
||||||
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
|
|
||||||
aria-label="Select all"
|
|
||||||
data-action-button="true"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: Row<Invoice> }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
|
||||||
aria-label="Select row"
|
|
||||||
data-action-button="true"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: "client.name",
|
accessorKey: "client.name",
|
||||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Client" />,
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Client" />
|
||||||
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const invoice = row.original;
|
const invoice = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="max-w-[80px] min-w-0 sm:max-w-[200px] lg:max-w-[300px]">
|
||||||
<div className="bg-primary/10 hidden p-2 sm:flex">
|
<p className="truncate font-medium">
|
||||||
<FileText className="text-primary h-4 w-4" />
|
{invoice.client?.name ?? "—"}
|
||||||
</div>
|
</p>
|
||||||
<div className="min-w-0 flex-1">
|
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
||||||
<p className="truncate font-medium">{invoice.client?.name ?? "—"}</p>
|
{invoice.invoiceNumber}
|
||||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">{invoice.invoiceNumber}</p>
|
</p>
|
||||||
<div className="mt-1 flex items-center gap-2 sm:hidden">
|
|
||||||
<StatusBadge status={getStatusType(invoice)} className="text-xs" />
|
|
||||||
<span className="text-foreground text-xs font-semibold">
|
|
||||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "issueDate",
|
accessorKey: "issueDate",
|
||||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
|
header: ({ column }) => (
|
||||||
cell: ({ row }) => (
|
<DataTableColumnHeader column={column} title="Date" />
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm">{formatDate(row.getValue("issueDate") as Date)}</p>
|
|
||||||
<p className="text-muted-foreground truncate text-xs">Due {formatDate(new Date(row.original.dueDate))}</p>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const date = row.getValue("issueDate");
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm">{formatDate(date as Date)}</p>
|
||||||
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
|
Due {formatDate(new Date(row.original.dueDate))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
|
header: ({ column }) => (
|
||||||
cell: ({ row }) => (
|
<DataTableColumnHeader column={column} title="Status" />
|
||||||
<StatusBadge
|
|
||||||
status={getStatusType(row.original)}
|
|
||||||
className={getStatusType(row.original) === "sent" ? "status-pending" : ""}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
filterFn: (row, _id, value: string[]) => value.includes(getStatusType(row.original)),
|
cell: ({ row }) => {
|
||||||
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" },
|
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",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "totalAmount",
|
accessorKey: "totalAmount",
|
||||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />,
|
header: ({ column }) => (
|
||||||
cell: ({ row }) => (
|
<DataTableColumnHeader column={column} title="Amount" />
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-sm font-semibold">
|
|
||||||
{formatCurrency(row.getValue("totalAmount") as number, row.original.currency)}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-xs">{row.original.items?.length ?? 0} items</p>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" },
|
cell: ({ row }) => {
|
||||||
|
const amount = row.getValue("totalAmount");
|
||||||
|
return (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{formatCurrency(amount as number)}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{row.original.items?.length ?? 0} items
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden sm:table-cell",
|
||||||
|
cellClassName: "hidden sm:table-cell",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
@@ -188,23 +172,25 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||||
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
data-action-button="true"
|
||||||
|
>
|
||||||
<Eye className="h-3.5 w-3.5" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
data-action-button="true"
|
||||||
|
>
|
||||||
<Edit className="h-3.5 w-3.5" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
|
||||||
variant="ghost" size="sm"
|
|
||||||
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
|
|
||||||
onClick={(e) => { e.stopPropagation(); setInvoiceToDelete(invoice); setDeleteDialogOpen(true); }}
|
|
||||||
data-action-button="true"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
{invoice.items && invoice.client && (
|
{invoice.items && invoice.client && (
|
||||||
<div data-action-button="true">
|
<div data-action-button="true">
|
||||||
<PDFDownloadButton invoiceId={invoice.id} variant="icon" />
|
<PDFDownloadButton invoiceId={invoice.id} variant="icon" />
|
||||||
@@ -230,124 +216,13 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<DataTable
|
||||||
<DataTable
|
columns={columns}
|
||||||
columns={columns}
|
data={invoices}
|
||||||
data={invoices}
|
searchKey="invoiceNumber"
|
||||||
searchKey="invoiceNumber"
|
searchPlaceholder="Search invoices..."
|
||||||
searchPlaceholder="Search invoices..."
|
filterableColumns={filterableColumns}
|
||||||
filterableColumns={filterableColumns}
|
onRowClick={handleRowClick}
|
||||||
onRowClick={(invoice) => router.push(`/dashboard/invoices/${invoice.id}`)}
|
/>
|
||||||
selectionActions={(selected, clear) => (
|
|
||||||
<>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" disabled={bulkUpdateStatus.isPending}>
|
|
||||||
<Send className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Mark as
|
|
||||||
<ChevronDown className="ml-1.5 h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
bulkUpdateStatus.mutate(
|
|
||||||
{ ids: selected.map((i) => i.id), status: "sent" },
|
|
||||||
{ onSuccess: clear },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Send className="mr-2 h-4 w-4" /> Mark Sent
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
bulkUpdateStatus.mutate(
|
|
||||||
{ ids: selected.map((i) => i.id), status: "paid" },
|
|
||||||
{ onSuccess: clear },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CheckCircle className="mr-2 h-4 w-4" /> Mark Paid
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
bulkUpdateStatus.mutate(
|
|
||||||
{ ids: selected.map((i) => i.id), status: "draft" },
|
|
||||||
{ onSuccess: clear },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FileText className="mr-2 h-4 w-4" /> Mark Draft
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
disabled={bulkDelete.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
setPendingBulkDelete(selected);
|
|
||||||
setBulkDeleteDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Delete ({selected.length})
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Single delete dialog */}
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Invoice</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete invoice{" "}
|
|
||||||
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
|
|
||||||
<strong>{invoiceToDelete?.client?.name}</strong>? This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleteInvoice.isPending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => invoiceToDelete && deleteInvoice.mutate({ id: invoiceToDelete.id })}
|
|
||||||
disabled={deleteInvoice.isPending}
|
|
||||||
>
|
|
||||||
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Bulk delete dialog */}
|
|
||||||
<Dialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete {pendingBulkDelete.length} Invoice{pendingBulkDelete.length !== 1 ? "s" : ""}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
This will permanently delete {pendingBulkDelete.length} invoice{pendingBulkDelete.length !== 1 ? "s" : ""}.
|
|
||||||
This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setBulkDeleteDialogOpen(false)} disabled={bulkDelete.isPending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })}
|
|
||||||
disabled={bulkDelete.isPending}
|
|
||||||
>
|
|
||||||
{bulkDelete.isPending ? "Deleting..." : `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,37 @@
|
|||||||
import {
|
import { Suspense } from "react";
|
||||||
AlertCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
CheckCircle,
|
|
||||||
Download,
|
|
||||||
FileSpreadsheet,
|
|
||||||
FileText,
|
|
||||||
Info,
|
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
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";
|
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,
|
||||||
|
FileSpreadsheet,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
// File Upload Instructions Component
|
// File Upload Instructions Component
|
||||||
function FormatInstructions() {
|
function FormatInstructions() {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
{/* Required Format */}
|
{/* Required Format */}
|
||||||
<Card className="bg-card border-border border">
|
<Card className="card-primary">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
<CardTitle className="card-title-info">
|
||||||
<FileText className="text-primary h-5 w-5" />
|
<FileText className="text-icon-blue h-5 w-5" />
|
||||||
Required CSV Format
|
Required CSV Format
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="bg-muted/50 p-4">
|
<div className="bg-muted-subtle rounded-lg p-4">
|
||||||
<p className="text-muted-foreground font-mono text-sm">
|
<p className="text-secondary font-mono text-sm">
|
||||||
DATE,DESCRIPTION,HOURS,RATE,AMOUNT
|
DATE,DESCRIPTION,HOURS,RATE,AMOUNT
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,7 +50,7 @@ function FormatInstructions() {
|
|||||||
},
|
},
|
||||||
].map((col) => (
|
].map((col) => (
|
||||||
<div key={col.field} className="flex items-start gap-3">
|
<div key={col.field} className="flex items-start gap-3">
|
||||||
<Badge className="border text-xs">{col.field}</Badge>
|
<Badge className="badge-outline text-xs">{col.field}</Badge>
|
||||||
<span className="text-muted-foreground text-sm">
|
<span className="text-muted-foreground text-sm">
|
||||||
{col.desc}
|
{col.desc}
|
||||||
</span>
|
</span>
|
||||||
@@ -72,10 +73,10 @@ function FormatInstructions() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Sample Data & Download */}
|
{/* Sample Data & Download */}
|
||||||
<Card className="bg-card border-border border">
|
<Card className="card-primary">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
<CardTitle className="card-title-secondary">
|
||||||
<Download className="text-primary h-5 w-5" />
|
<Download className="text-icon-green h-5 w-5" />
|
||||||
Sample Template
|
Sample Template
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -85,9 +86,9 @@ function FormatInstructions() {
|
|||||||
for importing time entries.
|
for importing time entries.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-primary/10 p-4">
|
<div className="bg-green-subtle rounded-lg p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Info className="text-primary mt-0.5 h-5 w-5" />
|
<Info className="text-icon-green mt-0.5 h-5 w-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-success text-sm font-medium">Pro Tip</p>
|
<p className="text-success text-sm font-medium">Pro Tip</p>
|
||||||
<p className="text-success text-sm">
|
<p className="text-success text-sm">
|
||||||
@@ -100,7 +101,7 @@ function FormatInstructions() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold">Sample Row:</h4>
|
<h4 className="text-sm font-semibold">Sample Row:</h4>
|
||||||
<div className="bg-muted/50 p-3">
|
<div className="bg-muted-subtle rounded-lg p-3">
|
||||||
<p className="text-muted font-mono text-xs break-all">
|
<p className="text-muted font-mono text-xs break-all">
|
||||||
1/15/24,"Web development work",8,75.00,600.00
|
1/15/24,"Web development work",8,75.00,600.00
|
||||||
</p>
|
</p>
|
||||||
@@ -109,7 +110,7 @@ function FormatInstructions() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold">Sample Filename:</h4>
|
<h4 className="text-sm font-semibold">Sample Filename:</h4>
|
||||||
<div className="bg-muted/50 p-3">
|
<div className="bg-muted-subtle rounded-lg p-3">
|
||||||
<p className="text-muted font-mono text-xs">2024-01-15.csv</p>
|
<p className="text-muted font-mono text-xs">2024-01-15.csv</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,10 +123,10 @@ function FormatInstructions() {
|
|||||||
// Important Notes Section
|
// Important Notes Section
|
||||||
function ImportantNotes() {
|
function ImportantNotes() {
|
||||||
return (
|
return (
|
||||||
<Card className="bg-card border-border border border-l-4 border-l-amber-500">
|
<Card className="card-primary border-l-4 border-l-amber-500">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-destructive flex items-center gap-2">
|
<CardTitle className="card-title-warning">
|
||||||
<AlertCircle className="text-primary h-5 w-5" />
|
<AlertCircle className="text-icon-amber h-5 w-5" />
|
||||||
Important Notes
|
Important Notes
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -158,18 +159,18 @@ function ImportantNotes() {
|
|||||||
// File Format Help Section
|
// File Format Help Section
|
||||||
function FileFormatHelp() {
|
function FileFormatHelp() {
|
||||||
return (
|
return (
|
||||||
<Card className="bg-card border-border border">
|
<Card className="card-primary">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
<CardTitle className="card-title-info">
|
||||||
<FileSpreadsheet className="text-primary h-5 w-5" />
|
<FileSpreadsheet className="text-icon-blue h-5 w-5" />
|
||||||
Supported File Formats
|
Supported File Formats
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<div className="bg-accent mx-auto w-fit p-3">
|
<div className="mx-auto w-fit rounded-full bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||||
<FileSpreadsheet className="text-foreground-foreground h-6 w-6" />
|
<FileSpreadsheet className="h-6 w-6 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-semibold">CSV Files</h4>
|
<h4 className="font-semibold">CSV Files</h4>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
@@ -178,8 +179,8 @@ function FileFormatHelp() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<div className="bg-primary/10 mx-auto w-fit p-3">
|
<div className="mx-auto w-fit rounded-full bg-green-50 p-3 dark:bg-green-900/20">
|
||||||
<Upload className="text-primary h-6 w-6" />
|
<Upload className="h-6 w-6 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-semibold">Max Size</h4>
|
<h4 className="font-semibold">Max Size</h4>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
@@ -187,8 +188,8 @@ function FileFormatHelp() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<div className="bg-secondary mx-auto w-fit p-3">
|
<div className="mx-auto w-fit rounded-full bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||||
<CheckCircle className="text-muted-foreground-foreground h-6 w-6" />
|
<CheckCircle className="h-6 w-6 text-purple-600" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-semibold">Validation</h4>
|
<h4 className="font-semibold">Validation</h4>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
|
|||||||
@@ -1,7 +1,719 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import InvoiceForm from "~/components/forms/invoice-form";
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function NewInvoicePage() {
|
export default function NewInvoicePage() {
|
||||||
return <InvoiceForm />;
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,19 +16,19 @@ async function InvoicesTable() {
|
|||||||
|
|
||||||
export default async function InvoicesPage() {
|
export default async function InvoicesPage() {
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Invoices"
|
title="Invoices"
|
||||||
description="Manage your invoices and track payments"
|
description="Manage your invoices and track payments"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<Button asChild variant="outline" className="hover-lift shadow-sm">
|
<Button asChild variant="outline" className="shadow-sm">
|
||||||
<Link href="/dashboard/invoices/import">
|
<Link href="/dashboard/invoices/import">
|
||||||
<Upload className="mr-2 h-5 w-5" />
|
<Upload className="mr-2 h-5 w-5" />
|
||||||
<span>Import CSV</span>
|
<span>Import CSV</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
<Button asChild className="btn-brand-primary shadow-md">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
<span>Create Invoice</span>
|
<span>Create Invoice</span>
|
||||||
@@ -41,6 +41,6 @@ export default async function InvoicesPage() {
|
|||||||
<InvoicesTable />
|
<InvoicesTable />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
"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,9 +1,30 @@
|
|||||||
import { DashboardShell } from "~/components/layout/dashboard-shell";
|
import { Navbar } from "~/components/layout/navbar";
|
||||||
|
import { Sidebar } from "~/components/layout/sidebar";
|
||||||
|
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return <DashboardShell>{children}</DashboardShell>;
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+249
-329
@@ -1,211 +1,193 @@
|
|||||||
import {
|
|
||||||
Activity,
|
|
||||||
ArrowUpRight,
|
|
||||||
BarChart3,
|
|
||||||
Calendar,
|
|
||||||
Edit,
|
|
||||||
Eye,
|
|
||||||
FileText,
|
|
||||||
Plus,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Suspense } from "react";
|
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 { HydrateClient, api } from "~/trpc/server";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { RevenueChart } from "~/app/dashboard/_components/revenue-chart";
|
import { Button } from "~/components/ui/button";
|
||||||
import { InvoiceStatusChart } from "~/app/dashboard/_components/invoice-status-chart";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { MonthlyMetricsChart } from "~/app/dashboard/_components/monthly-metrics-chart";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
|
import { auth } from "~/server/auth";
|
||||||
import type { DashboardStats, RecentInvoice } from "./types";
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
FileText,
|
||||||
|
DollarSign,
|
||||||
|
TrendingUp,
|
||||||
|
Plus,
|
||||||
|
ArrowUpRight,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Eye,
|
||||||
|
Edit,
|
||||||
|
Activity,
|
||||||
|
BarChart3,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
// Hero section with clean mono design
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced stats cards with better visual hierarchy
|
||||||
|
async function DashboardStats() {
|
||||||
|
const [clients, invoices] = await Promise.all([
|
||||||
|
api.clients.getAll(),
|
||||||
|
api.invoices.getAll(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Enhanced stats cards with better visuals
|
const totalClients = clients.length;
|
||||||
function DashboardStats({ stats }: { stats: DashboardStats }) { // TODO: Import RouterOutput type
|
const totalInvoices = invoices.length;
|
||||||
const formatTrend = (value: number, isCount = false) => {
|
const totalRevenue = invoices
|
||||||
if (isCount) {
|
.filter((invoice) => invoice.status === "paid")
|
||||||
return value > 0 ? `+${value}` : value.toString();
|
.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
||||||
}
|
const pendingAmount = invoices
|
||||||
return value > 0 ? `+${value.toFixed(1)}%` : `${value.toFixed(1)}%`;
|
.filter((invoice) => invoice.status === "sent")
|
||||||
};
|
.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
||||||
|
|
||||||
const statCards = [
|
const stats = [
|
||||||
{
|
{
|
||||||
title: "Total Revenue",
|
title: "Total Revenue",
|
||||||
value: `$${stats.totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
value: `$${totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||||
numericValue: stats.totalRevenue,
|
change: "+12.5%",
|
||||||
isCurrency: true,
|
icon: DollarSign,
|
||||||
change: formatTrend(stats.revenueChange),
|
color: "",
|
||||||
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const),
|
bgColor: "bg-green-50",
|
||||||
iconName: "DollarSign" as const,
|
changeColor: "",
|
||||||
description: "Total collected revenue",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Pending Amount",
|
title: "Pending Amount",
|
||||||
value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
value: `$${pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||||
numericValue: stats.pendingAmount,
|
change: "+8.2%",
|
||||||
isCurrency: true,
|
icon: Clock,
|
||||||
change: "0%", // TODO: Calculate pending change if needed
|
color: "",
|
||||||
trend: "neutral" as const,
|
bgColor: "bg-amber-50",
|
||||||
iconName: "Clock" as const,
|
changeColor: "",
|
||||||
description: "Invoices awaiting payment",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Active Clients",
|
title: "Active Clients",
|
||||||
value: stats.totalClients.toString(),
|
value: totalClients.toString(),
|
||||||
numericValue: stats.totalClients,
|
change: "+3",
|
||||||
isCurrency: false,
|
icon: Users,
|
||||||
change: "0", // TODO: Calculate client change if needed
|
color: "",
|
||||||
trend: "neutral" as const,
|
bgColor: "bg-blue-50",
|
||||||
iconName: "Users" as const,
|
changeColor: "",
|
||||||
description: "Total registered clients",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Overdue Invoices",
|
title: "Total Invoices",
|
||||||
value: stats.overdueCount.toString(),
|
value: totalInvoices.toString(),
|
||||||
numericValue: stats.overdueCount,
|
change: "+15",
|
||||||
isCurrency: false,
|
icon: FileText,
|
||||||
change: "0", // TODO: Calculate overdue change if needed
|
color: "",
|
||||||
trend: "neutral" as const,
|
bgColor: "bg-purple-50",
|
||||||
iconName: "TrendingDown" as const,
|
changeColor: "",
|
||||||
description: "Invoices past due date",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mb-8 grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4">
|
||||||
{statCards.map((stat, index) => (
|
{stats.map((stat) => {
|
||||||
<AnimatedStatsCard
|
const Icon = stat.icon;
|
||||||
key={stat.title}
|
return (
|
||||||
title={stat.title}
|
<Card
|
||||||
value={stat.value}
|
key={stat.title}
|
||||||
numericValue={stat.numericValue}
|
className="border-0 shadow-sm transition-shadow hover:shadow-md"
|
||||||
isCurrency={stat.isCurrency}
|
>
|
||||||
iconName={stat.iconName}
|
<CardContent className="p-3 sm:p-4 lg:p-6">
|
||||||
change={stat.change}
|
<div className="mb-2 flex items-center justify-between sm:mb-3 lg:mb-4">
|
||||||
trend={stat.trend}
|
<div className={`rounded-lg p-1.5 sm:p-2 ${stat.bgColor}`}>
|
||||||
description={stat.description}
|
<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" />
|
||||||
delay={index * 100}
|
</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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charts section
|
// Quick Actions with better visual design
|
||||||
async function ChartsSection({ stats }: { stats: DashboardStats }) {
|
|
||||||
// We still fetch all invoices for the status chart for now, or we could aggregate that too.
|
|
||||||
// For now, let's keep status chart as is (fetching all) but use aggregated for revenue.
|
|
||||||
// Actually, let's fetch invoices here for the status chart to keep it working.
|
|
||||||
const invoices = await api.invoices.getAll();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
{/* Revenue Trend Chart */}
|
|
||||||
<Card className="lg:col-span-2">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<BarChart3 className="h-5 w-5" />
|
|
||||||
Revenue Over Time
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<RevenueChart data={stats.revenueChartData} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Invoice Status Breakdown */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Activity className="h-5 w-5" />
|
|
||||||
Invoice Status
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<InvoiceStatusChart invoices={invoices} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Monthly Metrics */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Calendar className="h-5 w-5" />
|
|
||||||
Monthly Metrics
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<MonthlyMetricsChart invoices={invoices} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced Quick Actions
|
|
||||||
function QuickActions() {
|
function QuickActions() {
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
title: "Create Invoice",
|
title: "Create Invoice",
|
||||||
description: "Start a new invoice for a client",
|
description: "Start a new invoice",
|
||||||
href: "/dashboard/invoices/new",
|
href: "/dashboard/invoices/new",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
featured: true,
|
primary: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Add Client",
|
title: "Add Client",
|
||||||
description: "Register a new client",
|
description: "Add a new client",
|
||||||
href: "/dashboard/clients/new",
|
href: "/dashboard/clients/new",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
featured: false,
|
primary: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "View All Invoices",
|
title: "View Reports",
|
||||||
description: "Manage your invoice pipeline",
|
description: "Business analytics",
|
||||||
href: "/dashboard/invoices",
|
href: "/dashboard/reports",
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
featured: false,
|
primary: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5 text-teal-600 dark:text-teal-400" />
|
||||||
Quick Actions
|
Quick Actions
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-2">
|
||||||
{actions.map((action) => {
|
{actions.map((action) => {
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Button
|
||||||
key={action.title}
|
key={action.title}
|
||||||
href={action.href}
|
asChild
|
||||||
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${action.featured
|
variant={action.primary ? "default" : "outline"}
|
||||||
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
className={`h-12 w-full justify-start px-3 ${
|
||||||
: "border-border bg-background hover:bg-muted/50"
|
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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
<Link href={action.href}>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex items-center gap-3">
|
||||||
<p className="font-semibold">{action.title}</p>
|
<Icon
|
||||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
className={`h-4 w-4 ${action.primary ? "text-white" : "text-gray-600 dark:text-gray-300"}`}
|
||||||
{action.description}
|
/>
|
||||||
</p>
|
<span
|
||||||
</div>
|
className={`font-medium ${action.primary ? "text-white" : "text-gray-900 dark:text-gray-100"}`}
|
||||||
</Link>
|
>
|
||||||
|
{action.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -213,35 +195,30 @@ function QuickActions() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current work section with enhanced design
|
// Current work in progress
|
||||||
async function CurrentWork() {
|
async function CurrentWork() {
|
||||||
const invoices = await api.invoices.getAll();
|
const invoices = await api.invoices.getAll();
|
||||||
const draftInvoices = invoices.filter(
|
const draftInvoices = invoices.filter(
|
||||||
(invoice) =>
|
(invoice) => invoice.status === "draft",
|
||||||
getEffectiveInvoiceStatus(
|
|
||||||
invoice.status as StoredInvoiceStatus,
|
|
||||||
invoice.dueDate,
|
|
||||||
) === "draft",
|
|
||||||
);
|
);
|
||||||
const currentInvoice = draftInvoices[0];
|
const currentInvoice = draftInvoices[0];
|
||||||
|
|
||||||
if (!currentInvoice) {
|
if (!currentInvoice) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<Activity className="h-5 w-5" />
|
<Activity className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
Current Work
|
Current Work
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="py-8 text-center">
|
<div className="py-8 text-center">
|
||||||
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-300 dark:text-gray-600" />
|
||||||
<h3 className="mb-2 text-lg font-semibold">No active drafts</h3>
|
<p className="mb-4 text-gray-600 dark:text-gray-300">
|
||||||
<p className="text-muted-foreground mb-4">
|
No draft invoices found
|
||||||
Create a new invoice to get started
|
|
||||||
</p>
|
</p>
|
||||||
<Button asChild variant="outline" className="border-foreground/20">
|
<Button asChild className="bg-teal-600 hover:bg-teal-700">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create Invoice
|
Create Invoice
|
||||||
@@ -257,48 +234,49 @@ async function CurrentWork() {
|
|||||||
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
|
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<Activity className="h-5 w-5" />
|
<Activity className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
Current Work
|
Current Work
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant="secondary">In Progress</Badge>
|
<Badge variant="secondary">In Progress</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div>
|
||||||
<h3 className="text-lg font-semibold break-words">
|
<p className="text-lg font-semibold">
|
||||||
#{currentInvoice.invoiceNumber}
|
#{currentInvoice.invoiceNumber}
|
||||||
</h3>
|
</p>
|
||||||
<span className="text-primary text-2xl font-bold">
|
<p className="text-gray-600 dark:text-gray-300">
|
||||||
${currentInvoice.totalAmount.toFixed(2)}
|
{currentInvoice.client?.name}
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between">
|
<div className="text-right">
|
||||||
<span className="break-words">{currentInvoice.client?.name}</span>
|
<p className="text-2xl font-bold text-teal-600 dark:text-teal-400">
|
||||||
<span className="text-xs sm:text-sm">
|
${currentInvoice.totalAmount.toFixed(2)}
|
||||||
{totalHours.toFixed(1)} hours logged
|
</p>
|
||||||
</span>
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{totalHours.toFixed(1)} hours
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="hover-lift flex-1"
|
|
||||||
>
|
|
||||||
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-3 w-3" />
|
||||||
View
|
View
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm" className="hover-lift flex-1">
|
<Button
|
||||||
|
asChild
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 bg-teal-600 hover:bg-teal-700"
|
||||||
|
>
|
||||||
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-3 w-3" />
|
||||||
Continue
|
Continue
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -309,62 +287,51 @@ async function CurrentWork() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced recent activity
|
// Recent activity with enhanced design
|
||||||
async function RecentActivity({ recentInvoices }: { recentInvoices: RecentInvoice[] }) {
|
async function RecentActivity() {
|
||||||
// Use passed recentInvoices instead of fetching all
|
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);
|
||||||
|
|
||||||
const getStatusStyle = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "paid":
|
case "paid":
|
||||||
return {
|
return "bg-green-50 border-green-200";
|
||||||
backgroundColor: "oklch(var(--chart-2) / 0.1)",
|
|
||||||
borderColor: "oklch(var(--chart-2) / 0.3)",
|
|
||||||
color: "oklch(var(--chart-2))",
|
|
||||||
};
|
|
||||||
case "sent":
|
case "sent":
|
||||||
return {
|
return "bg-blue-50 border-blue-200";
|
||||||
backgroundColor: "oklch(var(--chart-1) / 0.1)",
|
|
||||||
borderColor: "oklch(var(--chart-1) / 0.3)",
|
|
||||||
color: "oklch(var(--chart-1))",
|
|
||||||
};
|
|
||||||
case "overdue":
|
case "overdue":
|
||||||
return {
|
return "bg-red-50 border-red-200";
|
||||||
backgroundColor: "oklch(var(--chart-3) / 0.1)",
|
|
||||||
borderColor: "oklch(var(--chart-3) / 0.3)",
|
|
||||||
color: "oklch(var(--chart-3))",
|
|
||||||
};
|
|
||||||
default:
|
default:
|
||||||
return {
|
return "bg-gray-50 border-gray-200";
|
||||||
backgroundColor: "hsl(var(--muted))",
|
|
||||||
borderColor: "hsl(var(--border))",
|
|
||||||
color: "hsl(var(--muted-foreground))",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<Calendar className="h-5 w-5" />
|
<Calendar className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||||
Recent Activity
|
Recent Activity
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/dashboard/invoices">
|
<Link href="/dashboard/invoices">
|
||||||
<span className="hidden sm:inline">View All</span>
|
View All
|
||||||
<ArrowUpRight className="h-4 w-4 sm:ml-1" />
|
<ArrowUpRight className="ml-1 h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{recentInvoices.length === 0 ? (
|
{recentInvoices.length === 0 ? (
|
||||||
<div className="py-8 text-center">
|
<div className="py-8 text-center">
|
||||||
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-300 dark:text-gray-600" />
|
||||||
<h3 className="mb-2 text-lg font-semibold">No invoices yet</h3>
|
<p className="mb-4 text-gray-600 dark:text-gray-300">
|
||||||
<p className="text-muted-foreground mb-4">
|
No invoices yet
|
||||||
Create your first invoice to get started
|
|
||||||
</p>
|
</p>
|
||||||
<Button asChild variant="outline" className="border-foreground/20">
|
<Button asChild className="bg-teal-600 hover:bg-teal-700">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create Your First Invoice
|
Create Your First Invoice
|
||||||
@@ -373,42 +340,45 @@ async function RecentActivity({ recentInvoices }: { recentInvoices: RecentInvoic
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{recentInvoices.map((invoice, _index) => (
|
{recentInvoices.map((invoice) => (
|
||||||
<Link
|
<Link
|
||||||
key={invoice.id}
|
key={invoice.id}
|
||||||
href={`/dashboard/invoices/${invoice.id}`}
|
href={`/dashboard/invoices/${invoice.id}`}
|
||||||
className="block"
|
className="block"
|
||||||
>
|
>
|
||||||
<div className="recent-activity-item bg-muted/50 hover:bg-muted border-foreground/20 rounded-lg border p-3 transition-colors">
|
<Card className="card-secondary transition-colors hover:bg-gray-200/70 dark:hover:bg-gray-700/60">
|
||||||
<div className="flex items-start gap-3">
|
<CardContent className="p-4">
|
||||||
<div className="bg-muted flex-shrink-0 rounded-lg p-2">
|
<div className="space-y-3">
|
||||||
<FileText className="text-muted-foreground h-4 w-4" />
|
<div className="flex items-center gap-3">
|
||||||
</div>
|
<div className="rounded-lg bg-gray-100 p-2 dark:bg-gray-700">
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-300" />
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-medium">
|
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
#{invoice.invoiceNumber}
|
#{invoice.invoiceNumber}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground truncate text-sm">
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
{invoice.client?.name}
|
{invoice.client?.name} •{" "}
|
||||||
|
{new Date(invoice.issueDate).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-shrink-0 items-center gap-2">
|
<div className="rounded-lg p-1 transition-colors hover:bg-gray-300/50 dark:hover:bg-gray-600/50">
|
||||||
<Badge style={getStatusStyle(invoice.status)}>
|
<Eye className="h-4 w-4 text-gray-600 dark:text-gray-300" />
|
||||||
{invoice.status}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-primary font-semibold">
|
|
||||||
${invoice.totalAmount.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs">
|
<div className="flex items-center justify-between">
|
||||||
{new Date(invoice.issueDate).toLocaleDateString()}
|
<Badge
|
||||||
</p>
|
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>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -421,16 +391,16 @@ async function RecentActivity({ recentInvoices }: { recentInvoices: RecentInvoic
|
|||||||
// Loading skeletons
|
// Loading skeletons
|
||||||
function StatsSkeleton() {
|
function StatsSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mb-8 grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<Card key={i}>
|
<Card key={i} className="border-0 shadow-sm">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-3 sm:p-4 lg:p-6">
|
||||||
<div className="flex items-center justify-between space-y-0 pb-2">
|
<div className="mb-2 flex items-center justify-between sm:mb-3 lg:mb-4">
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-6 w-6 rounded-lg sm:h-8 sm:w-8 lg:h-9 lg:w-9" />
|
||||||
<Skeleton className="h-4 w-12" />
|
<Skeleton className="h-3 w-8 sm:h-4 sm:w-12" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="mb-2 h-8 w-20" />
|
<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-32" />
|
<Skeleton className="h-3 w-20 sm:h-4 sm:w-24" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -438,40 +408,9 @@ function StatsSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChartsSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
<Card className="lg:col-span-2">
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-6 w-40" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-6 w-32" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-6 w-36" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardSkeleton() {
|
function CardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -486,54 +425,35 @@ function CardSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
|
||||||
|
|
||||||
// ... imports
|
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth();
|
||||||
headers: await headers(),
|
|
||||||
});
|
|
||||||
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
||||||
|
|
||||||
// Fetch stats centrally
|
|
||||||
const stats = await api.dashboard.getStats();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<div className="space-y-8">
|
||||||
<DashboardPageHeader
|
<DashboardHero firstName={firstName} />
|
||||||
title={`Welcome back, ${firstName}!`}
|
|
||||||
description="Here's what's happening with your business today"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<Suspense fallback={<StatsSkeleton />}>
|
<Suspense fallback={<StatsSkeleton />}>
|
||||||
<DashboardStats stats={stats} />
|
<DashboardStats />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
|
|
||||||
<HydrateClient>
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||||
<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>
|
<HydrateClient>
|
||||||
<Suspense fallback={<CardSkeleton />}>
|
<Suspense fallback={<CardSkeleton />}>
|
||||||
<RecentActivity recentInvoices={stats.recentInvoices} />
|
<CurrentWork />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
|
|
||||||
|
<QuickActions />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<HydrateClient>
|
||||||
|
<Suspense fallback={<CardSkeleton />}>
|
||||||
|
<RecentActivity />
|
||||||
|
</Suspense>
|
||||||
|
</HydrateClient>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,459 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { StatusBadge } from "~/components/data/status-badge";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
|
||||||
import { Separator } from "~/components/ui/separator";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
|
||||||
import { formatCurrency } from "~/lib/currency";
|
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
|
||||||
import {
|
|
||||||
AreaChart,
|
|
||||||
Area,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from "recharts";
|
|
||||||
import { TrendingUp, DollarSign, Clock, Users, Download, Receipt, FileText } from "lucide-react";
|
|
||||||
|
|
||||||
function toNumericChartValue(value: unknown) {
|
|
||||||
const numericValue = typeof value === "number" ? value : Number(value ?? 0);
|
|
||||||
return Number.isFinite(numericValue) ? numericValue : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReportsPage() {
|
|
||||||
const { data: invoices = [], isLoading: invoicesLoading } = api.invoices.getAll.useQuery();
|
|
||||||
const { data: expenses = [], isLoading: expensesLoading } = api.expenses.getAll.useQuery();
|
|
||||||
const { data: stats } = api.dashboard.getStats.useQuery();
|
|
||||||
|
|
||||||
const isLoading = invoicesLoading || expensesLoading;
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const currentYear = now.getFullYear();
|
|
||||||
const [taxYear, setTaxYear] = useState(String(currentYear));
|
|
||||||
|
|
||||||
// Overview data (last 12 months)
|
|
||||||
const overviewData = useMemo(() => {
|
|
||||||
if (!invoices.length) return null;
|
|
||||||
|
|
||||||
const monthMap: Record<string, number> = {};
|
|
||||||
for (let i = 11; i >= 0; i--) {
|
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
|
||||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
||||||
monthMap[key] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalRevenue = 0;
|
|
||||||
let totalPending = 0;
|
|
||||||
let totalHours = 0;
|
|
||||||
|
|
||||||
for (const inv of invoices) {
|
|
||||||
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
|
||||||
if (status === "paid") {
|
|
||||||
totalRevenue += inv.totalAmount;
|
|
||||||
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`;
|
|
||||||
if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount;
|
|
||||||
} else if (status === "sent" || status === "overdue") {
|
|
||||||
totalPending += inv.totalAmount;
|
|
||||||
}
|
|
||||||
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
|
|
||||||
month: new Date(month + "-01").toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
|
|
||||||
revenue,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
|
||||||
for (const inv of invoices) {
|
|
||||||
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
|
||||||
if (status === "paid" && inv.client) {
|
|
||||||
const id = inv.client.id;
|
|
||||||
if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0 };
|
|
||||||
clientMap[id]!.revenue += inv.totalAmount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const topClients = Object.values(clientMap).sort((a, b) => b.revenue - a.revenue).slice(0, 6);
|
|
||||||
|
|
||||||
const statusCount: Record<string, number> = { draft: 0, sent: 0, paid: 0, overdue: 0 };
|
|
||||||
for (const inv of invoices) {
|
|
||||||
const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
|
||||||
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, statusCount };
|
|
||||||
}, [invoices]);
|
|
||||||
|
|
||||||
// Tax summary for selected year
|
|
||||||
const taxData = useMemo(() => {
|
|
||||||
const year = parseInt(taxYear);
|
|
||||||
|
|
||||||
const yearInvoices = invoices.filter((inv) => {
|
|
||||||
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
|
||||||
return status === "paid" && new Date(inv.issueDate).getFullYear() === year;
|
|
||||||
});
|
|
||||||
const yearExpenses = expenses.filter((exp) => new Date(exp.date).getFullYear() === year);
|
|
||||||
|
|
||||||
const grossIncome = yearInvoices.reduce((s, inv) => s + inv.totalAmount, 0);
|
|
||||||
const taxCollected = yearInvoices.reduce((s, inv) => s + inv.totalAmount * (inv.taxRate ?? 0), 0);
|
|
||||||
const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0);
|
|
||||||
const deductibleExpenses = yearExpenses
|
|
||||||
.filter((exp) => (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible)
|
|
||||||
.reduce((s, exp) => s + exp.amount, 0);
|
|
||||||
|
|
||||||
const netProfit = grossIncome - deductibleExpenses;
|
|
||||||
const seTaxBase = Math.max(0, netProfit) * 0.9235;
|
|
||||||
const selfEmploymentTax = seTaxBase * 0.153;
|
|
||||||
const taxableIncome = Math.max(0, netProfit - selfEmploymentTax / 2);
|
|
||||||
const federalEstimate = taxableIncome * 0.22;
|
|
||||||
const totalEstimated = selfEmploymentTax + federalEstimate;
|
|
||||||
|
|
||||||
const quarters = [1, 2, 3, 4].map((q) => {
|
|
||||||
const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2];
|
|
||||||
return {
|
|
||||||
label: `Q${q}`,
|
|
||||||
income: yearInvoices.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth())).reduce((s, inv) => s + inv.totalAmount, 0),
|
|
||||||
expenses: yearExpenses.filter((exp) => qMonths.includes(new Date(exp.date).getMonth())).reduce((s, exp) => s + exp.amount, 0),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return { grossIncome, taxCollected, totalInvoiced: grossIncome + taxCollected, totalExpenses, deductibleExpenses, netProfit, selfEmploymentTax, federalEstimate, totalEstimated, quarters, yearInvoices, yearExpenses };
|
|
||||||
}, [invoices, expenses, taxYear]);
|
|
||||||
|
|
||||||
const availableYears = useMemo(() => {
|
|
||||||
const years = new Set<number>([currentYear, currentYear - 1]);
|
|
||||||
for (const inv of invoices) years.add(new Date(inv.issueDate).getFullYear());
|
|
||||||
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
|
||||||
return Array.from(years).sort((a, b) => b - a);
|
|
||||||
}, [invoices, expenses, currentYear]);
|
|
||||||
|
|
||||||
const avgInvoice = invoices.length > 0
|
|
||||||
? (overviewData?.totalRevenue ?? 0) / (invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 1)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
function exportCSV() {
|
|
||||||
const rows: string[] = [
|
|
||||||
`Tax Year ${taxYear} - Income & Expense Report`,
|
|
||||||
`Generated: ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`,
|
|
||||||
"",
|
|
||||||
"INCOME (Paid Invoices)",
|
|
||||||
"Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total",
|
|
||||||
...taxData.yearInvoices.map((inv) => {
|
|
||||||
const taxAmt = inv.totalAmount * (inv.taxRate ?? 0);
|
|
||||||
return [new Date(inv.issueDate).toLocaleDateString("en-US"), inv.invoiceNumber, `"${inv.client?.name ?? ""}"`, inv.totalAmount.toFixed(2), `${((inv.taxRate ?? 0) * 100).toFixed(1)}%`, taxAmt.toFixed(2), (inv.totalAmount + taxAmt).toFixed(2)].join(",");
|
|
||||||
}),
|
|
||||||
`,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`,
|
|
||||||
"",
|
|
||||||
"EXPENSES",
|
|
||||||
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
|
|
||||||
...taxData.yearExpenses.map((exp) => [
|
|
||||||
new Date(exp.date).toLocaleDateString("en-US"),
|
|
||||||
`"${exp.description}"`,
|
|
||||||
`"${exp.category ?? ""}"`,
|
|
||||||
exp.amount.toFixed(2),
|
|
||||||
exp.currency,
|
|
||||||
exp.billable ? "Yes" : "No",
|
|
||||||
exp.reimbursable ? "Yes" : "No",
|
|
||||||
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible ? "Yes" : "No",
|
|
||||||
].join(",")),
|
|
||||||
`,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`,
|
|
||||||
"",
|
|
||||||
"TAX SUMMARY",
|
|
||||||
`Gross Income,${taxData.grossIncome.toFixed(2)}`,
|
|
||||||
`Tax Collected,${taxData.taxCollected.toFixed(2)}`,
|
|
||||||
`Deductible Expenses,${taxData.deductibleExpenses.toFixed(2)}`,
|
|
||||||
`Net Profit,${taxData.netProfit.toFixed(2)}`,
|
|
||||||
`Est. Self-Employment Tax (15.3%),${taxData.selfEmploymentTax.toFixed(2)}`,
|
|
||||||
`Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`,
|
|
||||||
`Total Estimated Tax,${taxData.totalEstimated.toFixed(2)}`,
|
|
||||||
];
|
|
||||||
const blob = new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8;" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `tax-report-${taxYear}.csv`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="page-enter space-y-6">
|
|
||||||
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
|
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
||||||
{[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-enter space-y-6 pb-6">
|
|
||||||
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
|
|
||||||
|
|
||||||
<Tabs defaultValue="overview">
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="overview"><TrendingUp className="mr-1.5 h-4 w-4" /> Overview</TabsTrigger>
|
|
||||||
<TabsTrigger value="tax"><FileText className="mr-1.5 h-4 w-4" /> Tax Summary</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* ── OVERVIEW TAB ── */}
|
|
||||||
<TabsContent value="overview" className="mt-4 space-y-6">
|
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-primary/10 rounded p-1.5"><DollarSign className="text-primary h-4 w-4" /></div>
|
|
||||||
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalRevenue ?? 0)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-yellow-500/10 rounded p-1.5"><Clock className="h-4 w-4 text-yellow-500" /></div>
|
|
||||||
<p className="text-muted-foreground text-xs font-medium">Pending</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalPending ?? 0)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-blue-500/10 rounded p-1.5"><TrendingUp className="h-4 w-4 text-blue-500" /></div>
|
|
||||||
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-green-500/10 rounded p-1.5"><Users className="h-4 w-4 text-green-500" /></div>
|
|
||||||
<p className="text-muted-foreground text-xs font-medium">Total Hours</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-2xl font-bold">{(overviewData?.totalHours ?? 0).toFixed(1)}h</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="h-48 w-full md:h-64">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={overviewData?.revenueByMonth ?? []}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
|
|
||||||
<stop offset="95%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.02} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
||||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
|
|
||||||
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
|
||||||
<Tooltip formatter={(value) => [formatCurrency(toNumericChartValue(value)), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
|
||||||
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><Users className="h-5 w-5" /> Top Clients by Revenue</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{!overviewData?.topClients.length ? (
|
|
||||||
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="h-48 md:h-56">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={overviewData.topClients} layout="vertical">
|
|
||||||
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
|
||||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} />
|
|
||||||
<Tooltip formatter={(value) => [formatCurrency(toNumericChartValue(value)), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
|
||||||
<Bar dataKey="revenue" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader><CardTitle>Invoice Status Breakdown</CardTitle></CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{Object.entries(overviewData?.statusCount ?? {}).map(([status, count]) => (
|
|
||||||
<div key={status} className="flex items-center justify-between">
|
|
||||||
<StatusBadge status={status as never} />
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
|
|
||||||
<div className="bg-primary h-full rounded-full" style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{invoices.length === 0 && <p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{stats && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader><CardTitle>Recent Activity</CardTitle></CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="divide-y">
|
|
||||||
{stats.recentInvoices.map((inv) => (
|
|
||||||
<div key={inv.id} className="flex items-center justify-between py-3">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{inv.client?.name ?? "—"}</p>
|
|
||||||
<p className="text-muted-foreground text-xs">{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<StatusBadge status={getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate) as never} />
|
|
||||||
<p className="font-semibold">{formatCurrency(inv.totalAmount)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* ── TAX SUMMARY TAB ── */}
|
|
||||||
<TabsContent value="tax" className="mt-4 space-y-6">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm font-medium">Tax Year</span>
|
|
||||||
<Select value={taxYear} onValueChange={setTaxYear}>
|
|
||||||
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{availableYears.map((y) => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={exportCSV} className="gap-2">
|
|
||||||
<Download className="h-4 w-4" /> Export CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Income */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><DollarSign className="h-5 w-5" /> Income</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Gross Income (paid invoices)</span>
|
|
||||||
<span className="font-medium">{formatCurrency(taxData.grossIncome)}</span>
|
|
||||||
</div>
|
|
||||||
{taxData.taxCollected > 0 && (
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Tax Collected from Clients</span>
|
|
||||||
<span className="font-medium">{formatCurrency(taxData.taxCollected)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Separator />
|
|
||||||
<div className="flex justify-between font-medium">
|
|
||||||
<span>Total Invoiced (inc. tax)</span>
|
|
||||||
<span>{formatCurrency(taxData.totalInvoiced)}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Expenses */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><Receipt className="h-5 w-5" /> Expenses & Deductions</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Total Expenses</span>
|
|
||||||
<span className="font-medium">{formatCurrency(taxData.totalExpenses)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Tax-Deductible Expenses</span>
|
|
||||||
<span className="font-medium text-green-600">{formatCurrency(taxData.deductibleExpenses)}</span>
|
|
||||||
</div>
|
|
||||||
{taxData.totalExpenses > 0 && taxData.deductibleExpenses === 0 && (
|
|
||||||
<p className="text-muted-foreground text-xs">Mark expenses as "Tax Deductible" in the Expenses page to include them here.</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Estimated tax */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><FileText className="h-5 w-5" /> Estimated Tax Liability</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Net Profit (income − deductible expenses)</span>
|
|
||||||
<span className="font-medium">{formatCurrency(taxData.netProfit)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Self-Employment Tax (15.3% on 92.35% of net)</span>
|
|
||||||
<span className="font-medium">{formatCurrency(taxData.selfEmploymentTax)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Federal Income Tax (est. 22% bracket)</span>
|
|
||||||
<span className="font-medium">{formatCurrency(taxData.federalEstimate)}</span>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="flex justify-between text-lg font-bold">
|
|
||||||
<span>Total Estimated Tax</span>
|
|
||||||
<span className="text-destructive">{formatCurrency(taxData.totalEstimated)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-xs pt-1">
|
|
||||||
Assumes US self-employment tax rules and the 22% federal bracket. Consult a tax professional for accurate filing.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Quarterly chart */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader><CardTitle>Quarterly Breakdown</CardTitle></CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="h-48 md:h-64">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={taxData.quarters}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
||||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
|
|
||||||
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value, name) => [formatCurrency(toNumericChartValue(value)), name === "income" ? "Income" : "Expenses"]}
|
|
||||||
contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="income" name="income" fill="hsl(142, 76%, 36%)" radius={[4, 4, 0, 0]} />
|
|
||||||
<Bar dataKey="expenses" name="expenses" fill="hsl(0, 84%, 60%)" radius={[4, 4, 0, 0]} opacity={0.75} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex justify-center gap-6 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" /> Income</span>
|
|
||||||
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" /> Expenses</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,26 +3,21 @@ import { HydrateClient } from "~/trpc/server";
|
|||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
import { SettingsContent } from "./_components/settings-content";
|
import { SettingsContent } from "./_components/settings-content";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Settings"
|
title="Settings"
|
||||||
description="Manage your account preferences and data"
|
description="Manage your account preferences and data"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<HydrateClient>
|
||||||
<CardContent className="p-6">
|
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
||||||
<HydrateClient>
|
<SettingsContent />
|
||||||
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
</Suspense>
|
||||||
<SettingsContent />
|
</HydrateClient>
|
||||||
</Suspense>
|
</>
|
||||||
</HydrateClient>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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];
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user