19 Commits

Author SHA1 Message Date
soconnor ddc2b42672 Refactor invoice data table and templates page for improved readability and functionality
- Cleaned up imports and formatted code for better readability in invoices-data-table.tsx.
- Enhanced invoice interface definitions for clarity.
- Improved toast messages for bulk delete and update actions.
- Refactored date formatting and status type retrieval for better readability.
- Simplified template management in templates page, extracting TemplateList component.
- Added registration toggle based on environment variable DISABLE_SIGNUPS.
- Updated navbar to conditionally render registration link based on allowRegistration prop.
- Enhanced error handling and validation in expenses and settings routers.
- Improved PDF export footer handling.
- Updated TRPC react integration for cleaner type imports.
2026-04-29 22:49:07 -04:00
soconnor dbb739b060 refactor: update SendEmailPage layout and remove SendEmailDialog component 2026-04-28 01:30:38 -04:00
soconnor bd3181fb9d feat: add PDF preview functionality and normalize email message handling 2026-04-28 01:26:47 -04:00
soconnor 915ec103fc feat: add email message field to invoices and update related components 2026-04-28 01:06:45 -04:00
soconnor 4108019eab feat: enhance PDF generation with improved line estimation and page budgeting 2026-04-28 00:44:00 -04:00
soconnor 84a5d997b4 refactor: remove InvoiceView component and update related email and invoice handling
- Deleted the InvoiceView component to streamline the codebase.
- Updated EmailPreview and SendEmailDialog components to include currency and notes fields.
- Enhanced invoice-form to handle default hourly rates and improved item mapping.
- Refactored email template generation to include notes and currency formatting.
- Adjusted API routers for invoices to calculate totals and handle notes and currency correctly.
2026-04-28 00:34:56 -04:00
soconnor ad89ad001d feat: update Dockerfile and docker-compose.yml to use WEB_PORT variable and streamline migration process 2026-04-27 22:49:13 -04:00
soconnor 4fd6772f2e refactor: streamline Dockerfile and docker-compose.yml for improved build process 2026-04-27 22:41:57 -04:00
soconnor fbeca7cfee feat: remove start.sh script and add appearance preferences management
- Deleted the start.sh script for container management.
- Added AGENTS.md for project guidelines and development principles.
- Introduced new SQL migration files for user appearance preferences and platform settings.
- Implemented appearance provider to manage user interface themes and preferences.
- Created branding utility to define and manage branding-related constants and types.

Co-authored-by: Copilot <copilot@github.com>
2026-04-27 22:24:43 -04:00
soconnor b582b6c88e update pdf generation to flow better 2026-04-27 14:15:06 -04:00
soconnor 00e066ca4e fix: register frutiger-bold as pdf font 2026-04-27 13:33:40 -04:00
soconnor 4214a4b4de add invoice prefixes, currency passing to pdf gen 2026-04-10 01:28:14 -04:00
soconnor af392e1bc9 remove reordering controls, add auto sort 2026-04-09 23:27:45 -04:00
Claude 74f9696023 Add tax features: summary report, deductible expenses, invoice tax fix, CSV export
- Add taxDeductible boolean to expenses schema + migration 0002
- Update expenses router, form, and list to support tax-deductible flag
- Fix invoice-view tax calculation (was hardcoded $0.00; now uses taxRate)
- New Tax Summary tab in Reports: year selector, income/deductions breakdown,
  SE tax + federal income estimates, quarterly bar chart
- CSV export for accountant with income + expense rows and tax summary

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:21:08 +00:00
Claude 1f76cf38a7 Fix migrate: remove bogus tracking entries from broken baseline
The previous baseline blindly recorded all migrations as applied.
Now on startup the script validates every recorded migration against
the actual schema; any entry whose schema changes don't exist is
deleted so migrate() will re-run that migration.

This unblocks the existing deployment where 0001 was recorded as done
but beenvoice_client.currency was never actually added.

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:11:43 +00:00
Claude e5242b37a4 Fix baseline: only mark migrations applied if schema changes already exist
Previously the baseline marked ALL migrations as done, causing 0001 to
be skipped even on databases that didn't have the currency column yet.

Now each migration is checked against a sentinel column/table before
being seeded into the tracking table. Migrations whose changes don't
exist yet are left out so migrate() runs them normally.

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:08:34 +00:00
Claude 38206f34fe Handle baseline migration for databases previously set up with db:push
When switching from db:push to db:migrate on an existing database,
the migration table is empty so Drizzle tries to re-run all migrations,
failing with "relation already exists".

Detect this case (tables exist but no migration history) and seed the
__drizzle_migrations tracking table with all current migrations so
Drizzle treats them as already applied. Future migrations run normally.

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:06:08 +00:00
Claude e950abd805 Fix migration files excluded from Docker build and restore fonts
- Remove drizzle/*.sql and drizzle/*-journal from .dockerignore so
  migration files are included in the Docker build context
- Restore next/font/google imports (removed prematurely due to local
  IP being 403'd by Google Fonts; production builds should work fine)
- Update CSS font fallbacks to use proper system font stacks

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:03:45 +00:00
Claude 4c0eae4b11 Fix build: resolve Turbopack client bundle and font issues
- Move EXPENSE_CATEGORIES to ~/lib/expense-categories.ts to break
  server router import chain from client component
- Use inline import() types in trpc/react.tsx to prevent Turbopack
  from including server modules (pg, db) in the client bundle
- Replace next/font/google with system font stacks to fix build
  failures in environments without Google Fonts access

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:00:25 +00:00
69 changed files with 6038 additions and 3486 deletions
+1 -3
View File
@@ -6,14 +6,12 @@ Dockerfile*
docker-compose* docker-compose*
README.md README.md
*.log *.log
.DS_Store
.env* .env*
!.env.example !.env.example
drizzle/*.sql
drizzle/*-journal
.vscode .vscode
.idea .idea
coverage coverage
*.tsbuildinfo *.tsbuildinfo
dist dist
build build
+43 -35
View File
@@ -1,43 +1,51 @@
# Base application env # Copy this file to .env before running Docker Compose:
NODE_ENV="development" # cp .env.example .env
PORT="3000"
HOSTNAME="0.0.0.0" # Runtime
NODE_ENV=production
WEB_PORT=3000
# Auth # Auth
# You can generate a new secret on the command line with: # Generate with: openssl rand -base64 32
# openssl rand -base64 32 AUTH_SECRET=change-me-generate-a-real-secret
AUTH_SECRET="your-auth-secret" BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_URL="http://localhost:3000" # Set to your production URL in production
# App URL # Public app URL
# Used for client-side redirects and base URLs NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# Database (Postgres) # Postgres used by docker-compose.yml
# These are required for Docker container initialization POSTGRES_USER=postgres
POSTGRES_USER="postgres" POSTGRES_PASSWORD=postgres
POSTGRES_PASSWORD="postgres" POSTGRES_DB=postgres
POSTGRES_DB="postgres" DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
DB_DISABLE_SSL=true
# Connect string for the app # White-label defaults used at image build time.
DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres" # Admin-managed platform branding in the app can override these after setup.
# Disable SSL for Docker local Postgres; set to false or remove for managed Postgres NEXT_PUBLIC_BRAND_NAME="beenvoice"
DB_DISABLE_SSL="true" 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 (Resend). Replace with real keys in production # Email delivery via Resend (optional)
RESEND_API_KEY="your-resend-api-key" # Leave blank to disable invoice/password-reset email delivery.
RESEND_DOMAIN="" RESEND_API_KEY=
RESEND_DOMAIN=
# Analytics # Analytics via Umami (optional)
NEXT_PUBLIC_UMAMI_WEBSITE_ID="your-website-id-here" # Leave website ID blank to disable analytics.
NEXT_PUBLIC_UMAMI_SCRIPT_URL="https://analytics.umami.is/script.js" NEXT_PUBLIC_UMAMI_WEBSITE_ID=
# Build tweaks NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
# SKIP_ENV_VALIDATION=1
# SSO / Authentik (Optional - only needed if using SSO authentication) # SSO via Authentik OIDC (optional)
# Configure these if you want to enable Single Sign-On with Authentik OIDC NEXT_PUBLIC_AUTHENTIK_ENABLED=false
# The issuer should be your Authentik application's OAuth2 provider URL AUTHENTIK_ISSUER=
# Example: https://auth.example.com/application/o/your-app-slug AUTHENTIK_CLIENT_ID=
AUTHENTIK_ISSUER="" AUTHENTIK_CLIENT_SECRET=
AUTHENTIK_CLIENT_ID="" AUTHENTIK_ORIGIN=
AUTHENTIK_CLIENT_SECRET=""
+2 -1
View File
@@ -34,6 +34,7 @@ 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 .env*.production
@@ -41,4 +42,4 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
# idea files # idea files
.idea .idea
View File
+26 -49
View File
@@ -1,59 +1,36 @@
FROM oven/bun:1.2.19 as deps # syntax=docker/dockerfile:1
WORKDIR /app FROM oven/bun:1 AS base
WORKDIR /usr/src/app
# Install dependencies (only package manifests copied first for better caching) FROM base AS install
COPY package.json bun.lock ./ COPY package.json bun.lock ./
# Install minimal toolchain for native devDependencies (e.g., better-sqlite3) during build RUN bun install --frozen-lockfile
# Minimal toolchain (kept for safety, but we skip dev deps)
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& ln -sf /usr/bin/python3 /usr/bin/python \
&& rm -rf /var/lib/apt/lists/*
# Install all deps (including dev) for build tooling like @tailwindcss/postcss
RUN bun install --frozen-lockfile --verbose
FROM oven/bun:1.2.19 as builder FROM base AS build
WORKDIR /app COPY --from=install /usr/src/app/node_modules node_modules
ENV NODE_ENV=production
ENV SKIP_ENV_VALIDATION=1
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# Build Next.js app (no memory constraints) ENV NODE_ENV=production \
RUN bun run build 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 oven/bun:1.2.19 as runner FROM base AS release
WORKDIR /app ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0
ENV NODE_ENV=production COPY --from=build /usr/src/app/.next/standalone ./
ENV PORT=3000 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
# Create non-root user and group RUN chmod -R a+rX drizzle migrate.js public
RUN addgroup --system --gid 1001 beenvoice \
&& adduser --system --uid 1001 --ingroup beenvoice beenvoice
# Copy runtime artifacts and install production deps
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/bun.lock ./bun.lock
RUN bun install --frozen-lockfile --production --verbose
COPY --from=builder /app/start.sh ./start.sh
COPY --from=builder /app/next.config.js ./next.config.js
COPY --from=builder /app/src ./src
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder /app/.env.example ./.env.example
RUN chmod +x ./start.sh
USER 1001
USER bun
EXPOSE 3000 EXPOSE 3000
CMD ["sh", "-c", "bun migrate.js && bun server.js"]
CMD ["./start.sh"]
+38 -4
View File
@@ -44,22 +44,26 @@ A modern, professional invoicing application built for freelancers and small bus
### Quick Start ### Quick Start
1. **Clone the repository** 1. **Clone the repository**
```bash ```bash
git clone https://github.com/yourusername/beenvoice.git git clone https://github.com/yourusername/beenvoice.git
cd beenvoice cd beenvoice
``` ```
2. **Install dependencies** 2. **Install dependencies**
```bash ```bash
bun install bun install
``` ```
3. **Set up environment variables** 3. **Set up environment variables**
```bash ```bash
cp .env.example .env.local cp .env.example .env.local
``` ```
Edit `.env.local` and add your configuration: Edit `.env.local` and add your configuration:
```env ```env
# Database # Database
DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice" DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice"
@@ -78,17 +82,20 @@ A modern, professional invoicing application built for freelancers and small bus
RESEND_DOMAIN="yourdomain.com" RESEND_DOMAIN="yourdomain.com"
``` ```
4. **Start the database** 4. **Start the development database**
```bash ```bash
docker-compose up -d docker compose -f docker-compose.dev.yml up -d db
``` ```
5. **Push the database schema** 5. **Push the database schema**
```bash ```bash
bun run db:push bun run db:push
``` ```
6. **Start the development server** 6. **Start the development server**
```bash ```bash
bun run dev bun run dev
``` ```
@@ -123,7 +130,8 @@ beenvoice/
├── drizzle/ # Database migrations ├── drizzle/ # Database migrations
├── public/ # Static assets ├── public/ # Static assets
├── docs/ # Documentation ├── docs/ # Documentation
── docker-compose.yml # Local PostgreSQL setup ── docker-compose.yml # Deployment compose stack
└── docker-compose.dev.yml # Development overrides with exposed PostgreSQL
``` ```
## 🎯 Usage ## 🎯 Usage
@@ -155,12 +163,14 @@ beenvoice/
### 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 - Set default hourly rates per client
- Search and filter client list - Search and filter client list
#### Invoice Creation #### Invoice Creation
- Select from existing clients and business profiles - Select from existing clients and business profiles
- Add multiple line items with drag-and-drop reordering - Add multiple line items with drag-and-drop reordering
- Set custom rates per item - Set custom rates per item
@@ -169,12 +179,14 @@ beenvoice/
- Professional invoice formatting - Professional invoice formatting
#### Invoice Delivery #### Invoice Delivery
- Send invoices via email directly from the app - Send invoices via email directly from the app
- Rich text email composer with preview - Rich text email composer with preview
- Resend and re-deliver sent invoices - Resend and re-deliver sent invoices
- Track invoice status: Draft → Sent → Paid (+ Overdue) - Track invoice status: Draft → Sent → Paid (+ Overdue)
#### User Interface #### User Interface
- Clean, modern design - Clean, modern design
- Fully responsive — desktop, tablet, and mobile - Fully responsive — desktop, tablet, and mobile
- Intuitive navigation with breadcrumbs - Intuitive navigation with breadcrumbs
@@ -198,7 +210,8 @@ bun run db:studio # Open Drizzle Studio
bun run db:generate # Generate new migration bun run db:generate # Generate new migration
# Docker # Docker
bun run docker:up # Start local PostgreSQL via Docker bun run docker:up # Start deployment compose stack
bun run docker:dev:up # Start development compose stack with exposed PostgreSQL
bun run docker:down # Stop Docker services bun run docker:down # Stop Docker services
# Code Quality # Code Quality
@@ -208,6 +221,24 @@ bun run format:write # Format code with Prettier
bun run typecheck # Run TypeScript type checking bun run typecheck # Run TypeScript type checking
``` ```
### Docker Compose
Use the base compose file for deployment. It keeps PostgreSQL internal to the
compose network:
```bash
docker compose up -d
```
For local development, use the dev compose file to expose PostgreSQL on
`${POSTGRES_PORT:-5432}`:
```bash
docker compose -f docker-compose.dev.yml up -d
```
Set `DISABLE_SIGNUPS=true` to block new email/password account registration.
### Database Schema ### Database Schema
The application uses the following core tables: The application uses the following core tables:
@@ -243,6 +274,7 @@ The app uses Tailwind CSS v4 with a custom design system:
### Branding ### Branding
Update the logo and colors in: Update the logo and colors in:
- `src/components/logo.tsx` - Main logo component - `src/components/logo.tsx` - Main logo component
- `src/styles/globals.css` - Color variables - `src/styles/globals.css` - Color variables
- `src/app/layout.tsx` - Font configuration - `src/app/layout.tsx` - Font configuration
@@ -252,6 +284,7 @@ Update the logo and colors in:
You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.). You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.).
1. **Build the application:** 1. **Build the application:**
```bash ```bash
bun run build bun run build
``` ```
@@ -259,6 +292,7 @@ You can deploy this application to any platform that supports Next.js and Postgr
2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production) 2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production)
3. **Run database migrations:** 3. **Run database migrations:**
```bash ```bash
bun run db:push bun run db:push
``` ```
+294 -304
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes:
- beenvoice_dev_pg_data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s
timeout: 5s
retries: 10
ports:
- "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped
volumes:
beenvoice_dev_pg_data:
+35 -9
View File
@@ -1,21 +1,47 @@
services: services:
app:
build:
context: .
image: beenvoice:local
environment:
NODE_ENV: production
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
ports:
- "${WEB_PORT:-3000}:3000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db: db:
image: postgres:17-alpine image: postgres:17-alpine
container_name: beenvoice-db environment:
env_file: POSTGRES_USER: ${POSTGRES_USER:-postgres}
- .env.local POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes: volumes:
- beenvoice_pg_data:/var/lib/postgresql/data - beenvoice_pg_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
restart: unless-stopped
volumes: volumes:
beenvoice_pg_data: beenvoice_pg_data:
driver: local
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "beenvoice_expense" ADD COLUMN "taxDeductible" boolean DEFAULT false NOT NULL;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "beenvoice_user" ADD COLUMN "interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL;
ALTER TABLE "beenvoice_user" ADD COLUMN "fontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
@@ -0,0 +1,11 @@
ALTER TABLE "beenvoice_user"
ADD COLUMN "bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_user"
ADD COLUMN "headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_user"
ADD COLUMN "radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_user"
ADD COLUMN "sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL;
@@ -0,0 +1,59 @@
ALTER TABLE "beenvoice_user"
ADD COLUMN "role" varchar(20) DEFAULT 'user' NOT NULL;
--> statement-breakpoint
UPDATE "beenvoice_user"
SET "role" = 'admin'
WHERE "id" = (
SELECT "id"
FROM "beenvoice_user"
ORDER BY "createdAt" ASC
LIMIT 1
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "beenvoice_platform_setting" (
"id" varchar(50) PRIMARY KEY DEFAULT 'global' NOT NULL,
"brandName" varchar(100) DEFAULT 'beenvoice' NOT NULL,
"brandTagline" varchar(255) DEFAULT 'Simple and efficient invoicing for freelancers and small businesses' NOT NULL,
"brandLogoText" varchar(100) DEFAULT 'beenvoice' NOT NULL,
"brandIcon" varchar(20) DEFAULT '$' NOT NULL,
"colorTheme" varchar(50) DEFAULT 'slate' NOT NULL,
"customColor" varchar(50),
"theme" varchar(20) DEFAULT 'system' NOT NULL,
"interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL,
"bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
"headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
"radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL,
"sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
INSERT INTO "beenvoice_platform_setting" (
"id",
"brandName",
"brandTagline",
"brandLogoText",
"brandIcon",
"colorTheme",
"customColor",
"theme",
"interfaceTheme",
"bodyFontPreference",
"headingFontPreference",
"radiusPreference",
"sidebarStyle"
) VALUES (
'global',
'beenvoice',
'Simple and efficient invoicing for freelancers and small businesses',
'beenvoice',
'$',
'slate',
NULL,
'system',
'beenvoice',
'brand',
'brand',
'xl',
'floating'
) ON CONFLICT ("id") DO NOTHING;
+14
View File
@@ -0,0 +1,14 @@
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfTemplate" varchar(20) DEFAULT 'classic' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfAccentColor" varchar(50) DEFAULT '#111827' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfFooterText" varchar(120) DEFAULT 'Professional Invoicing' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfShowLogo" boolean DEFAULT true NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfShowPageNumbers" boolean DEFAULT true NOT NULL;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "beenvoice_invoice"
ADD COLUMN "emailMessage" varchar(2000);
+43 -1
View File
@@ -15,6 +15,48 @@
"when": 1775356013998, "when": 1775356013998,
"tag": "0001_supreme_the_enforcers", "tag": "0001_supreme_the_enforcers",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1775400000000,
"tag": "0002_tax_deductible",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1775600000000,
"tag": "0003_appearance_preferences",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1777336000000,
"tag": "0004_platform_appearance_controls",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1777337000000,
"tag": "0005_platform_settings_and_roles",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1777338000000,
"tag": "0006_pdf_generation_settings",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1777339000000,
"tag": "0007_invoice_email_message",
"breakpoints": true
} }
] ]
} }
+2 -1
View File
@@ -6,7 +6,8 @@ import "./src/env.js";
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
serverExternalPackages: ['pg'], output: "standalone",
serverExternalPackages: ["pg"],
}; };
export default config; export default config;
+6 -4
View File
@@ -7,12 +7,14 @@
"build": "next build", "build": "next build",
"check": "eslint . && tsc --noEmit", "check": "eslint . && tsc --noEmit",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "bun src/server/db/migrate.ts", "db:migrate": "bun drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:clone": "./scripts/clone-local.sh", "db:clone": "./scripts/clone-local.sh",
"docker:up": "colima start && docker-compose up -d", "docker:up": "colima start && docker compose up -d",
"docker:down": "docker-compose down && colima stop", "docker:dev:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
"docker:down": "docker compose down && colima stop",
"docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"deploy": "drizzle-kit push && next build", "deploy": "drizzle-kit push && next build",
"dev": "next dev --turbo", "dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
@@ -64,6 +66,7 @@
"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.44.7", "drizzle-orm": "^0.44.7",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
@@ -92,7 +95,6 @@
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"baseline-browser-mapping": "^2.9.6", "baseline-browser-mapping": "^2.9.6",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.30.6", "drizzle-kit": "^0.30.6",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^16.0.10", "eslint-config-next": "^16.0.10",
+14
View File
@@ -55,6 +55,20 @@ export async function POST(request: NextRequest) {
}) })
.where(eq(users.id, user.id)); .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 // Send password reset email using Resend
try { try {
const resend = new Resend(env.RESEND_API_KEY); const resend = new Resend(env.RESEND_API_KEY);
+55 -17
View File
@@ -2,59 +2,97 @@ import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { env } from "~/env";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
const registerSchema = z.object({ const registerSchema = z.object({
firstName: z.string().min(1, "First name is required"), firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"), lastName: z.string().trim().min(1, "Last name is required"),
email: z.string().email("Invalid email address"), email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"), password: z.string().min(8, "Password must be at least 8 characters"),
}); });
const fieldLabels: Record<string, string> = {
firstName: "First name",
lastName: "Last name",
email: "Email address",
password: "Password",
};
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() as z.infer<typeof registerSchema>; if (env.DISABLE_SIGNUPS === true) {
return NextResponse.json(
{ error: "New account registration is currently disabled" },
{ status: 403 },
);
}
const body = (await request.json()) as unknown;
const { firstName, lastName, email, password } = registerSchema.parse(body); const { firstName, lastName, email, password } = registerSchema.parse(body);
const normalizedEmail = email.toLowerCase();
// Check if user already exists // Check if user already exists
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.email, email), where: eq(users.email, normalizedEmail),
}); });
if (existingUser) { if (existingUser) {
return NextResponse.json( return NextResponse.json(
{ error: "User with this email already exists" }, { error: "User with this email already exists" },
{ status: 400 } { status: 400 },
); );
} }
// Hash password // Hash password
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
// Create user await db.transaction(async (tx) => {
await db.insert(users).values({ const [user] = await tx
name: `${firstName} ${lastName}`, .insert(users)
email, .values({
password: hashedPassword, name: `${firstName} ${lastName}`,
email: normalizedEmail,
password: hashedPassword,
})
.returning({ id: users.id });
if (!user) {
throw new Error("Failed to create user");
}
await tx.insert(accounts).values({
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
});
}); });
return NextResponse.json( return NextResponse.json(
{ message: "User created successfully" }, { message: "User created successfully" },
{ status: 201 } { status: 201 },
); );
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
const issue = error.errors[0];
const field = issue?.path[0];
const fallback =
typeof field === "string"
? `${fieldLabels[field] ?? field} is required`
: "Please check the registration form";
return NextResponse.json( return NextResponse.json(
{ error: error.errors[0]?.message ?? "Validation error" }, { error: issue?.message === "Required" ? fallback : issue?.message },
{ status: 400 } { status: 400 },
); );
} }
console.error("Registration error:", error); console.error("Registration error:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },
{ status: 500 } { status: 500 },
); );
} }
} }
+35 -10
View File
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm"; import { eq, and, gt } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -47,15 +47,40 @@ export async function POST(request: NextRequest) {
// Hash the new password // Hash the new password
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
// Update user with new password and clear reset token await db.transaction(async (tx) => {
await db await tx
.update(users) .update(users)
.set({ .set({
password: hashedPassword, password: hashedPassword,
resetToken: null, resetToken: null,
resetTokenExpiry: null, resetTokenExpiry: null,
}) })
.where(eq(users.id, user.id)); .where(eq(users.id, user.id));
const credentialAccount = await tx.query.accounts.findFirst({
where: and(
eq(accounts.userId, user.id),
eq(accounts.providerId, "credential"),
),
});
if (credentialAccount) {
await tx
.update(accounts)
.set({
password: hashedPassword,
updatedAt: new Date(),
})
.where(eq(accounts.id, credentialAccount.id));
} else {
await tx.insert(accounts).values({
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
});
}
});
return NextResponse.json( return NextResponse.json(
{ {
+2 -1
View File
@@ -28,7 +28,8 @@ function RegisterForm() {
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,
}), }),
+4 -270
View File
@@ -1,277 +1,11 @@
"use client"; import { Suspense } from "react";
import { env } from "~/env";
import { useState, Suspense } from "react"; import { SignInForm } from "./signin-form";
import { useRouter, useSearchParams } from "next/navigation";
import { authClient } from "~/lib/auth-client";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalModal } from "~/components/ui/legal-modal";
import {
Mail,
Lock,
ArrowRight,
Users,
FileText,
TrendingUp,
Shield,
} from "lucide-react";
function SignInForm() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const { error } = await authClient.signIn.email({
email,
password,
});
setLoading(false);
if (error) {
toast.error(error.message ?? "Invalid email or password");
} else {
toast.success("Signed in successfully!");
router.push(callbackUrl);
router.refresh();
}
}
async function handleSocialSignIn() {
setLoading(true);
try {
await authClient.signIn.oauth2({
providerId: "authentik",
callbackURL: callbackUrl,
});
// The signIn.sso method will automatically redirect to the SSO provider
} catch (error) {
console.error("[SSO Error]", error);
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center relative overflow-hidden">
{/* Blob Background */}
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="w-[800px] h-[800px] bg-neutral-400/30 dark:bg-neutral-500/20 rounded-full blur-3xl animate-blob"></div>
</div>
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:border md:shadow-2xl md:bg-background/80 md:backdrop-blur-xl md:border-border/50 md:rounded-3xl">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-primary/5 relative hidden md:flex md:flex-col md:justify-center md:p-12 border-r border-border/50">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="text-3xl font-bold lg:text-4xl font-heading">
Welcome back to your
<span className="text-primary italic"> invoicing workspace</span>
</h1>
<p className="text-muted-foreground text-lg">
Continue managing your clients and creating professional
invoices that get you paid faster.
</p>
</div>
</div>
<div className="grid gap-6">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<Users className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold text-foreground">Client Management</h3>
<p className="text-muted-foreground text-sm">
Organize and track all your clients in one place
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<FileText className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold text-foreground">Professional Invoices</h3>
<p className="text-muted-foreground text-sm">
Beautiful templates that get you paid faster
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<TrendingUp className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold text-foreground">Payment Tracking</h3>
<p className="text-muted-foreground text-sm">
Monitor your income with real-time insights
</p>
</div>
</div>
</div>
</div>
</div>
{/* Sign In Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center md:text-left">
<h1 className="text-3xl font-bold font-heading">Sign In</h1>
<p className="text-muted-foreground">
Enter your credentials to access your account
</p>
</div>
<div className="space-y-4">
<Button
variant="outline"
type="button"
className="w-full h-11 relative rounded-xl"
onClick={handleSocialSignIn}
disabled={loading}
>
<Shield className="mr-2 h-4 w-4" />
Sign in with Authentik
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border/50" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
</div>
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email 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 bg-background/50 border-border/60 focus:bg-background transition-all"
placeholder="m@example.com"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password"
className="text-primary text-sm hover:underline"
>
Forgot password?
</a>
</div>
<div className="relative">
<Lock className="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 bg-background/50 border-border/60 focus:bg-background transition-all"
placeholder="Enter your password"
/>
</div>
</div>
<Button
type="submit"
className="h-11 w-full rounded-xl text-base shadow-lg shadow-primary/20 hover:shadow-primary/30"
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&apos;t have an account?{" "}
<a
href="/auth/register"
className="text-primary font-medium hover:underline"
>
Sign up
</a>
</div>
<div className="text-muted-foreground text-center text-xs leading-relaxed">
By signing in, you agree to our{" "}
<LegalModal
type="terms"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Terms of Service
</span>
}
/>{" "}
and{" "}
<LegalModal
type="privacy"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Privacy Policy
</span>
}
/>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function SignInPage() { export default function SignInPage() {
return ( return (
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
<SignInForm /> <SignInForm allowRegistration={env.DISABLE_SIGNUPS !== true} />
</Suspense> </Suspense>
); );
} }
+303
View File
@@ -0,0 +1,303 @@
"use client";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { authClient } from "~/lib/auth-client";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalModal } from "~/components/ui/legal-modal";
import { env } from "~/env";
import {
Mail,
Lock,
ArrowRight,
Users,
FileText,
TrendingUp,
Shield,
} from "lucide-react";
interface SignInFormProps {
allowRegistration: boolean;
}
export function SignInForm({ allowRegistration }: SignInFormProps) {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const signupDisabled = searchParams.get("signup") === "disabled";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const { error } = await authClient.signIn.email({
email,
password,
});
setLoading(false);
if (error) {
toast.error(error.message ?? "Invalid email or password");
} else {
toast.success("Signed in successfully!");
router.push(callbackUrl);
router.refresh();
}
}
async function handleSocialSignIn() {
setLoading(true);
try {
await authClient.signIn.oauth2({
providerId: "authentik",
callbackURL: callbackUrl,
});
// The signIn.sso method will automatically redirect to the SSO provider
} catch (error) {
console.error("[SSO Error]", error);
setLoading(false);
}
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
{/* Blob Background */}
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div>
<Card className="md:bg-background/80 md:border-border/50 mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:rounded-3xl md:border md:shadow-2xl md:backdrop-blur-xl">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-primary/5 border-border/50 relative hidden border-r md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="font-heading text-3xl font-bold lg:text-4xl">
Welcome back to your
<span className="text-primary italic">
{" "}
invoicing workspace
</span>
</h1>
<p className="text-muted-foreground text-lg">
Continue managing your clients and creating professional
invoices that get you paid faster.
</p>
</div>
</div>
<div className="grid gap-6">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<Users className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Client Management
</h3>
<p className="text-muted-foreground text-sm">
Organize and track all your clients in one place
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<FileText className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Professional Invoices
</h3>
<p className="text-muted-foreground text-sm">
Beautiful templates that get you paid faster
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<TrendingUp className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Payment Tracking
</h3>
<p className="text-muted-foreground text-sm">
Monitor your income with real-time insights
</p>
</div>
</div>
</div>
</div>
</div>
{/* Sign In Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center md:text-left">
<h1 className="font-heading text-3xl font-bold">Sign In</h1>
<p className="text-muted-foreground">
Enter your credentials to access your account
</p>
</div>
{signupDisabled && (
<div className="border-border bg-muted/50 text-muted-foreground rounded-lg border px-3 py-2 text-sm">
New account registration is currently disabled.
</div>
)}
{authentikEnabled && (
<div className="space-y-4">
<Button
variant="outline"
type="button"
className="relative h-11 w-full rounded-xl"
onClick={handleSocialSignIn}
disabled={loading}
>
<Shield className="mr-2 h-4 w-4" />
Sign in with Authentik
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="border-border/50 w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
Or continue with
</span>
</div>
</div>
</div>
)}
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="m@example.com"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password"
className="text-primary text-sm hover:underline"
>
Forgot password?
</a>
</div>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="Enter your password"
/>
</div>
</div>
<Button
type="submit"
className="shadow-primary/20 hover:shadow-primary/30 h-11 w-full rounded-xl text-base shadow-lg"
disabled={loading}
>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
<span>Signing in...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Sign In</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
{allowRegistration && (
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a
href="/auth/register"
className="text-primary font-medium hover:underline"
>
Sign up
</a>
</div>
)}
<div className="text-muted-foreground text-center text-xs leading-relaxed">
By signing in, you agree to our{" "}
<LegalModal
type="terms"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Terms of Service
</span>
}
/>{" "}
and{" "}
<LegalModal
type="privacy"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Privacy Policy
</span>
}
/>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function SignInPageClient() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SignInForm allowRegistration />
</Suspense>
);
}
+20 -5
View File
@@ -29,7 +29,7 @@ import { NumberInput } from "~/components/ui/number-input";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Pencil, Trash2, Receipt } from "lucide-react"; import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency"; import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
import { EXPENSE_CATEGORIES } from "~/server/api/routers/expenses"; import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
interface ExpenseFormData { interface ExpenseFormData {
date: Date; date: Date;
@@ -39,6 +39,7 @@ interface ExpenseFormData {
category: string; category: string;
billable: boolean; billable: boolean;
reimbursable: boolean; reimbursable: boolean;
taxDeductible: boolean;
notes: string; notes: string;
clientId: string; clientId: string;
} }
@@ -51,6 +52,7 @@ const defaultForm: ExpenseFormData = {
category: "", category: "",
billable: false, billable: false,
reimbursable: false, reimbursable: false,
taxDeductible: false,
notes: "", notes: "",
clientId: "", clientId: "",
}; };
@@ -89,6 +91,7 @@ export default function ExpensesPage() {
category: expense.category ?? "", category: expense.category ?? "",
billable: expense.billable, billable: expense.billable,
reimbursable: expense.reimbursable, reimbursable: expense.reimbursable,
taxDeductible: expense.taxDeductible ?? false,
notes: expense.notes ?? "", notes: expense.notes ?? "",
clientId: expense.clientId ?? "", clientId: expense.clientId ?? "",
}); });
@@ -97,13 +100,14 @@ export default function ExpensesPage() {
const handleSubmit = () => { const handleSubmit = () => {
if (!form.description.trim()) { toast.error("Description is required"); return; } if (!form.description.trim()) { toast.error("Description is required"); return; }
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); 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 }; 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 }); if (editId) update.mutate({ id: editId, ...payload });
else create.mutate(payload); else create.mutate(payload);
}; };
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0); 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 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 ( return (
<div className="page-enter space-y-6 pb-6"> <div className="page-enter space-y-6 pb-6">
@@ -114,7 +118,7 @@ export default function ExpensesPage() {
</PageHeader> </PageHeader>
{/* Summary cards */} {/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p> <p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
@@ -127,7 +131,13 @@ export default function ExpensesPage() {
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p> <p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="col-span-2 sm:col-span-1"> <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"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p> <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> <p className="mt-1 text-2xl font-bold">{expenses.length}</p>
@@ -159,6 +169,7 @@ export default function ExpensesPage() {
<p className="font-medium">{expense.description}</p> <p className="font-medium">{expense.description}</p>
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>} {expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</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>} {expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>}
</div> </div>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
@@ -229,7 +240,7 @@ export default function ExpensesPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex gap-6"> <div className="flex flex-wrap gap-6">
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} /> <Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
<span className="text-sm">Billable</span> <span className="text-sm">Billable</span>
@@ -238,6 +249,10 @@ export default function ExpensesPage() {
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} /> <Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
<span className="text-sm">Reimbursable</span> <span className="text-sm">Reimbursable</span>
</label> </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>
<div className="space-y-2"> <div className="space-y-2">
<Label>Notes (optional)</Label> <Label>Notes (optional)</Label>
@@ -30,15 +30,15 @@ export function InvoiceDetailsSkeleton() {
<Skeleton className="h-8 w-48" /> <Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-24 rounded-full" /> <Skeleton className="h-6 w-24 rounded-full" />
</div> </div>
<div className="space-y-1 sm:space-y-0 text-sm"> <div className="space-y-1 text-sm sm:space-y-0">
<div className="flex gap-2"> <div className="flex gap-2">
<Skeleton className="h-4 w-32" /> <Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-32 hidden sm:block" /> <Skeleton className="hidden h-4 w-32 sm:block" />
</div> </div>
</div> </div>
</div> </div>
<div className="flex-shrink-0 text-left sm:text-right"> <div className="flex-shrink-0 text-left sm:text-right">
<Skeleton className="h-4 w-24 mb-1 sm:ml-auto" /> <Skeleton className="mb-1 h-4 w-24 sm:ml-auto" />
<Skeleton className="h-9 w-32 sm:ml-auto" /> <Skeleton className="h-9 w-32 sm:ml-auto" />
</div> </div>
</div> </div>
@@ -118,7 +118,7 @@ export function InvoiceDetailsSkeleton() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"> <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">
<Skeleton className="h-5 w-3/4 mb-2" /> <Skeleton className="mb-2 h-5 w-3/4" />
<div className="flex gap-4"> <div className="flex gap-4">
<Skeleton className="h-4 w-24" /> <Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" /> <Skeleton className="h-4 w-16" />
@@ -156,7 +156,7 @@ export function InvoiceDetailsSkeleton() {
{/* Right Column - Actions */} {/* Right Column - Actions */}
<div className="space-y-6"> <div className="space-y-6">
<Card className="sticky top-20"> <Card className="lg:sticky lg:top-6">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded-full" /> <Skeleton className="h-5 w-5 rounded-full" />
@@ -25,6 +25,9 @@ export function PDFDownloadButton({
{ id: invoiceId }, { id: invoiceId },
{ enabled: false }, { enabled: false },
); );
const { data: platformTheme } = api.settings.getTheme.useQuery(undefined, {
staleTime: 60_000,
});
const handleDownloadPDF = async () => { const handleDownloadPDF = async () => {
if (isGenerating) return; if (isGenerating) return;
@@ -39,7 +42,29 @@ export function PDFDownloadButton({
throw new Error("Invoice not found"); throw new Error("Invoice not found");
} }
await generateInvoicePDF(invoiceData); // Map invoice to PDF format with currency support
const pdfData = {
invoiceNumber: invoiceData.invoiceNumber,
invoicePrefix: invoiceData.invoicePrefix,
issueDate: new Date(invoiceData.issueDate),
dueDate: new Date(invoiceData.dueDate),
status: invoiceData.status,
totalAmount: invoiceData.totalAmount,
taxRate: invoiceData.taxRate,
currency: invoiceData.currency ?? "USD",
notes: invoiceData.notes,
business: invoiceData.business,
client: invoiceData.client,
items: invoiceData.items,
};
await generateInvoicePDF(pdfData, {
pdfTemplate: platformTheme?.pdfTemplate,
pdfAccentColor: platformTheme?.pdfAccentColor,
pdfFooterText: platformTheme?.pdfFooterText,
pdfShowLogo: platformTheme?.pdfShowLogo,
pdfShowPageNumbers: platformTheme?.pdfShowPageNumbers,
});
toast.success("PDF downloaded successfully"); toast.success("PDF downloaded successfully");
} catch (error) { } catch (error) {
console.error("PDF generation error:", error); console.error("PDF generation error:", error);
@@ -1,127 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import { Send, Loader2 } from "lucide-react";
interface SendInvoiceButtonProps {
invoiceId: string;
variant?: "default" | "outline" | "ghost" | "icon";
className?: string;
showResend?: boolean;
}
export function SendInvoiceButton({
invoiceId,
variant = "outline",
className,
showResend = false,
}: SendInvoiceButtonProps) {
const [isSending, setIsSending] = useState(false);
// Get utils for cache invalidation
const utils = api.useUtils();
// Use the new email API mutation
const sendInvoiceMutation = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
// Show detailed success message with delivery info
toast.success(data.message, {
description: `Email ID: ${data.emailId}`,
duration: 5000,
});
// Refresh invoice data to show updated status
void utils.invoices.getById.invalidate({ id: invoiceId });
},
onError: (error) => {
// Enhanced error handling with specific error types
console.error("Email send error:", error);
let errorMessage = "Failed to send invoice email";
let errorDescription = "";
if (error.message.includes("Invalid recipient")) {
errorMessage = "Invalid Email Address";
errorDescription =
"Please check the client's email address and try again.";
} else if (error.message.includes("domain not verified")) {
errorMessage = "Email Configuration Issue";
errorDescription = "Please contact support to configure email sending.";
} else if (error.message.includes("rate limit")) {
errorMessage = "Too Many Emails";
errorDescription = "Please wait a moment before sending another email.";
} else if (error.message.includes("no email address")) {
errorMessage = "No Email Address";
errorDescription = "This client doesn't have an email address on file.";
} else {
errorDescription = error.message;
}
toast.error(errorMessage, {
description: errorDescription,
duration: 6000,
});
},
});
const handleSendInvoice = async () => {
if (isSending) return;
setIsSending(true);
try {
await sendInvoiceMutation.mutateAsync({
invoiceId,
});
} catch (error) {
// Error is already handled by the mutation's onError
console.error("Send invoice error:", error);
} finally {
setIsSending(false);
}
};
if (variant === "icon") {
return (
<Button
onClick={handleSendInvoice}
disabled={isSending}
variant="ghost"
size="sm"
className={className}
>
{isSending ? (
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
) : (
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
)}
</Button>
);
}
return (
<Button
onClick={handleSendInvoice}
disabled={isSending}
variant={variant}
size="default"
className={`w-full shadow-sm ${className}`}
data-testid="send-invoice-button"
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Sending Email...</span>
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
<span>{showResend ? "Resend Invoice" : "Send Invoice"}</span>
</>
)}
</Button>
);
}
@@ -1,26 +0,0 @@
"use client";
import { InvoiceView } from "~/components/data/invoice-view";
import InvoiceForm from "~/components/forms/invoice-form";
interface UnifiedInvoicePageProps {
invoiceId: string;
mode: string;
}
export function UnifiedInvoicePage({
invoiceId,
mode,
}: UnifiedInvoicePageProps) {
return (
<div>
{/* Always render InvoiceForm to preserve state, but hide when in view mode */}
<div className={mode === "edit" ? "block" : "hidden"}>
<InvoiceForm invoiceId={invoiceId} />
</div>
{/* Show InvoiceView only when in view mode */}
{mode === "view" && <InvoiceView invoiceId={invoiceId} />}
</div>
);
}
+3 -3
View File
@@ -99,10 +99,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
}).format(new Date(date)); }).format(new Date(date));
}; };
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number, currency = invoice.currency) => {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency,
}).format(amount); }).format(amount);
}; };
@@ -411,7 +411,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
{/* Right Column - Actions */} {/* Right Column - Actions */}
<div className="space-y-6"> <div className="space-y-6">
<Card className="sticky top-20"> <Card className="lg:sticky lg:top-6">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Check className="h-5 w-5" /> <Check className="h-5 w-5" />
+97 -43
View File
@@ -54,6 +54,32 @@ function SendEmailPageSkeleton() {
); );
} }
function plainTextToHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\n/g, "<br>");
}
function normalizeEmailNoteHtml(value: string) {
const visibleText = value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;|\u00a0/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
return visibleText ? value.trim() : "";
}
export default function SendEmailPage() { export default function SendEmailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
@@ -136,9 +162,9 @@ export default function SendEmailPage() {
action: action:
canRetry && retryCount < 2 canRetry && retryCount < 2
? { ? {
label: "Retry", label: "Retry",
onClick: () => handleRetry(), onClick: () => handleRetry(),
} }
: undefined, : undefined,
}); });
@@ -150,34 +176,45 @@ export default function SendEmailPage() {
const invoice = useMemo(() => { const invoice = useMemo(() => {
return invoiceData return invoiceData
? { ? {
id: invoiceData.id, id: invoiceData.id,
invoiceNumber: invoiceData.invoiceNumber, invoiceNumber: invoiceData.invoiceNumber,
issueDate: invoiceData.issueDate, issueDate: invoiceData.issueDate,
dueDate: invoiceData.dueDate, dueDate: invoiceData.dueDate,
status: invoiceData.status, status: invoiceData.status,
taxRate: invoiceData.taxRate, totalAmount: invoiceData.totalAmount,
client: invoiceData.client taxRate: invoiceData.taxRate,
? { currency: invoiceData.currency,
name: invoiceData.client.name, emailMessage: invoiceData.emailMessage,
email: invoiceData.client.email, client: invoiceData.client
} ? {
: undefined, name: invoiceData.client.name,
business: invoiceData.business email: invoiceData.client.email,
? { }
name: invoiceData.business.name, : undefined,
nickname: invoiceData.business.nickname, business: invoiceData.business
email: invoiceData.business.email, ? {
} name: invoiceData.business.name,
: undefined, nickname: invoiceData.business.nickname,
items: invoiceData.items?.map((item) => ({ email: invoiceData.business.email,
id: item.id, }
hours: item.hours, : undefined,
rate: item.rate, items: invoiceData.items?.map((item) => ({
})), id: item.id,
} date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
})),
}
: undefined; : undefined;
}, [invoiceData]); }, [invoiceData]);
const normalizedCustomMessage = useMemo(
() => normalizeEmailNoteHtml(customMessage),
[customMessage],
);
// Initialize email content when invoice loads // Initialize email content when invoice loads
useEffect(() => { useEffect(() => {
if (!invoice || isInitialized) return; if (!invoice || isInitialized) return;
@@ -191,6 +228,9 @@ export default function SendEmailPage() {
const defaultContent = ``; const defaultContent = ``;
setEmailContent(defaultContent); setEmailContent(defaultContent);
setCustomMessage(
invoice.emailMessage ? plainTextToHtml(invoice.emailMessage) : "",
);
setIsInitialized(true); setIsInitialized(true);
}, [invoice, isInitialized]); }, [invoice, isInitialized]);
@@ -222,7 +262,7 @@ export default function SendEmailPage() {
invoiceId, invoiceId,
customSubject: subject, customSubject: subject,
customContent: emailContent, customContent: emailContent,
customMessage: customMessage?.trim() || undefined, customMessage: normalizedCustomMessage,
useHtml: true, useHtml: true,
ccEmails: ccEmail.trim() || undefined, ccEmails: ccEmail.trim() || undefined,
bccEmails: bccEmail.trim() || undefined, bccEmails: bccEmail.trim() || undefined,
@@ -252,7 +292,7 @@ export default function SendEmailPage() {
if (!invoice) { if (!invoice) {
return ( return (
<div className="container mx-auto max-w-4xl p-6"> <div className="page-enter space-y-6">
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertDescription>Invoice not found.</AlertDescription> <AlertDescription>Invoice not found.</AlertDescription>
@@ -262,7 +302,7 @@ export default function SendEmailPage() {
} }
return ( return (
<div className="container mx-auto max-w-6xl space-y-6 pb-32"> <div className="page-enter space-y-6 pb-32">
<PageHeader <PageHeader
title={`Send Invoice ${invoice.invoiceNumber}`} title={`Send Invoice ${invoice.invoiceNumber}`}
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat( description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat(
@@ -366,7 +406,7 @@ export default function SendEmailPage() {
ccEmail={ccEmail} ccEmail={ccEmail}
bccEmail={bccEmail} bccEmail={bccEmail}
content={emailContent} content={emailContent}
customMessage={customMessage} customMessage={normalizedCustomMessage}
invoice={invoice} invoice={invoice}
className="min-w-0 border-0" className="min-w-0 border-0"
/> />
@@ -552,10 +592,9 @@ export default function SendEmailPage() {
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> <Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Send Invoice Email?</DialogTitle> <DialogTitle>Confirm</DialogTitle>
<DialogDescription> <DialogDescription>
This will send invoice #{invoice.invoiceNumber} to{" "} Send this invoice email to <strong>{toEmail}</strong>
<strong>{invoice.client?.email}</strong>
{ccEmail && ( {ccEmail && (
<> <>
{" "} {" "}
@@ -568,14 +607,30 @@ export default function SendEmailPage() {
and BCC to <strong>{bccEmail}</strong> and BCC to <strong>{bccEmail}</strong>
</> </>
)} )}
. ?
{retryCount > 0 && (
<div className="text-muted-foreground mt-2 text-sm">
Retry attempt {retryCount} of 2
</div>
)}
</DialogDescription> </DialogDescription>
{retryCount > 0 && (
<p className="text-muted-foreground text-sm">
Retry attempt {retryCount} of 2
</p>
)}
</DialogHeader> </DialogHeader>
<div className="bg-muted/30 space-y-2 border p-3 text-sm">
<div>
<span className="text-muted-foreground">Subject: </span>
<span className="font-medium">{subject}</span>
</div>
<div>
<span className="text-muted-foreground">Attachment: </span>
<span>invoice-{invoice.invoiceNumber}.pdf</span>
</div>
{normalizedCustomMessage && (
<div>
<span className="text-muted-foreground">Email note: </span>
<span>Included</span>
</div>
)}
</div>
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
@@ -584,8 +639,7 @@ export default function SendEmailPage() {
Cancel Cancel
</Button> </Button>
<Button onClick={confirmSendEmail} variant="default"> <Button onClick={confirmSendEmail} variant="default">
<Send className="mr-2 h-4 w-4" /> Confirm
Send Email
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -23,7 +23,15 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { Eye, Edit, Trash2, FileText, CheckCircle, Send, ChevronDown } from "lucide-react"; import {
Eye,
Edit,
Trash2,
FileText,
CheckCircle,
Send,
ChevronDown,
} from "lucide-react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
@@ -45,11 +53,28 @@ interface Invoice {
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;
} }
@@ -58,10 +83,17 @@ interface InvoicesDataTableProps {
} }
const getStatusType = (invoice: Invoice): StatusType => const getStatusType = (invoice: Invoice): StatusType =>
getEffectiveInvoiceStatus(invoice.status as StoredInvoiceStatus, invoice.dueDate) as StatusType; getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) as StatusType;
const formatDate = (date: Date) => const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { month: "short", day: "2-digit", year: "numeric" }).format(new Date(date)); new Intl.DateTimeFormat("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
}).format(new Date(date));
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const router = useRouter(); const router = useRouter();
@@ -84,7 +116,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const bulkDelete = api.invoices.bulkDelete.useMutation({ const bulkDelete = api.invoices.bulkDelete.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
toast.success(`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`); toast.success(
`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`,
);
void utils.invoices.getAll.invalidate(); void utils.invoices.getAll.invalidate();
setBulkDeleteDialogOpen(false); setBulkDeleteDialogOpen(false);
setPendingBulkDelete([]); setPendingBulkDelete([]);
@@ -94,7 +128,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({ const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
toast.success(`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`); toast.success(
`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`,
);
void utils.invoices.getAll.invalidate(); void utils.invoices.getAll.invalidate();
}, },
onError: (e) => toast.error(e.message ?? "Failed to update invoices"), onError: (e) => toast.error(e.message ?? "Failed to update invoices"),
@@ -105,7 +141,10 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
id: "select", id: "select",
header: ({ table }) => ( header: ({ table }) => (
<Checkbox <Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="Select all" aria-label="Select all"
data-action-button="true" data-action-button="true"
@@ -124,7 +163,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
}, },
{ {
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 (
@@ -133,10 +174,17 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<FileText className="text-primary h-4 w-4" /> <FileText className="text-primary h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-medium">{invoice.client?.name ?? "—"}</p> <p className="truncate font-medium">
<p className="text-muted-foreground truncate text-xs sm:text-sm">{invoice.invoiceNumber}</p> {invoice.client?.name ?? "—"}
</p>
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{invoice.invoiceNumber}
</p>
<div className="mt-1 flex items-center gap-2 sm:hidden"> <div className="mt-1 flex items-center gap-2 sm:hidden">
<StatusBadge status={getStatusType(invoice)} className="text-xs" /> <StatusBadge
status={getStatusType(invoice)}
className="text-xs"
/>
<span className="text-foreground text-xs font-semibold"> <span className="text-foreground text-xs font-semibold">
{formatCurrency(invoice.totalAmount, invoice.currency)} {formatCurrency(invoice.totalAmount, invoice.currency)}
</span> </span>
@@ -148,38 +196,59 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
}, },
{ {
accessorKey: "issueDate", accessorKey: "issueDate",
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Date" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm">{formatDate(row.getValue("issueDate") as Date)}</p> <p className="truncate text-sm">
<p className="text-muted-foreground truncate text-xs">Due {formatDate(new Date(row.original.dueDate))}</p> {formatDate(row.getValue("issueDate"))}
</p>
<p className="text-muted-foreground truncate text-xs">
Due {formatDate(new Date(row.original.dueDate))}
</p>
</div> </div>
), ),
}, },
{ {
accessorKey: "status", accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<StatusBadge <StatusBadge
status={getStatusType(row.original)} status={getStatusType(row.original)}
className={getStatusType(row.original) === "sent" ? "status-pending" : ""} className={
getStatusType(row.original) === "sent" ? "status-pending" : ""
}
/> />
), ),
filterFn: (row, _id, value: string[]) => value.includes(getStatusType(row.original)), filterFn: (row, _id, value: string[]) =>
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" }, value.includes(getStatusType(row.original)),
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
}, },
{ {
accessorKey: "totalAmount", accessorKey: "totalAmount",
header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Amount" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="text-right"> <div className="text-right">
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{formatCurrency(row.getValue("totalAmount") as number, row.original.currency)} {formatCurrency(row.getValue("totalAmount"), row.original.currency)}
</p>
<p className="text-muted-foreground text-xs">
{row.original.items?.length ?? 0} items
</p> </p>
<p className="text-muted-foreground text-xs">{row.original.items?.length ?? 0} items</p>
</div> </div>
), ),
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" }, meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
}, },
{ {
id: "actions", id: "actions",
@@ -188,19 +257,34 @@ 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="hover-scale h-8 w-8 p-0"
data-action-button="true"
>
<Eye className="h-3.5 w-3.5" /> <Eye className="h-3.5 w-3.5" />
</Button> </Button>
</Link> </Link>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}> <Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true"> <Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
>
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
</Button> </Button>
</Link> </Link>
<Button <Button
variant="ghost" size="sm" variant="ghost"
size="sm"
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0" className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
onClick={(e) => { e.stopPropagation(); setInvoiceToDelete(invoice); setDeleteDialogOpen(true); }} onClick={(e) => {
e.stopPropagation();
setInvoiceToDelete(invoice);
setDeleteDialogOpen(true);
}}
data-action-button="true" data-action-button="true"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -237,12 +321,18 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
searchKey="invoiceNumber" searchKey="invoiceNumber"
searchPlaceholder="Search invoices..." searchPlaceholder="Search invoices..."
filterableColumns={filterableColumns} filterableColumns={filterableColumns}
onRowClick={(invoice) => router.push(`/dashboard/invoices/${invoice.id}`)} onRowClick={(invoice) =>
router.push(`/dashboard/invoices/${invoice.id}`)
}
selectionActions={(selected, clear) => ( selectionActions={(selected, clear) => (
<> <>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={bulkUpdateStatus.isPending}> <Button
variant="outline"
size="sm"
disabled={bulkUpdateStatus.isPending}
>
<Send className="mr-1.5 h-3.5 w-3.5" /> <Send className="mr-1.5 h-3.5 w-3.5" />
Mark as Mark as
<ChevronDown className="ml-1.5 h-3.5 w-3.5" /> <ChevronDown className="ml-1.5 h-3.5 w-3.5" />
@@ -306,16 +396,24 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<DialogDescription> <DialogDescription>
Are you sure you want to delete invoice{" "} Are you sure you want to delete invoice{" "}
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "} <strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
<strong>{invoiceToDelete?.client?.name}</strong>? This action cannot be undone. <strong>{invoiceToDelete?.client?.name}</strong>? This action
cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleteInvoice.isPending}> <Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={deleteInvoice.isPending}
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => invoiceToDelete && deleteInvoice.mutate({ id: invoiceToDelete.id })} onClick={() =>
invoiceToDelete &&
deleteInvoice.mutate({ id: invoiceToDelete.id })
}
disabled={deleteInvoice.isPending} disabled={deleteInvoice.isPending}
> >
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"} {deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
@@ -325,25 +423,40 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
</Dialog> </Dialog>
{/* Bulk delete dialog */} {/* Bulk delete dialog */}
<Dialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}> <Dialog
open={bulkDeleteDialogOpen}
onOpenChange={setBulkDeleteDialogOpen}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete {pendingBulkDelete.length} Invoice{pendingBulkDelete.length !== 1 ? "s" : ""}</DialogTitle> <DialogTitle>
Delete {pendingBulkDelete.length} Invoice
{pendingBulkDelete.length !== 1 ? "s" : ""}
</DialogTitle>
<DialogDescription> <DialogDescription>
This will permanently delete {pendingBulkDelete.length} invoice{pendingBulkDelete.length !== 1 ? "s" : ""}. This will permanently delete {pendingBulkDelete.length} invoice
This action cannot be undone. {pendingBulkDelete.length !== 1 ? "s" : ""}. This action cannot be
undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setBulkDeleteDialogOpen(false)} disabled={bulkDelete.isPending}> <Button
variant="outline"
onClick={() => setBulkDeleteDialogOpen(false)}
disabled={bulkDelete.isPending}
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })} onClick={() =>
bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })
}
disabled={bulkDelete.isPending} disabled={bulkDelete.isPending}
> >
{bulkDelete.isPending ? "Deleting..." : `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`} {bulkDelete.isPending
? "Deleting..."
: `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+185 -74
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { api } from "~/trpc/react"; import { api, type RouterOutputs } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header"; import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
@@ -18,12 +18,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "~/components/ui/tabs";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react"; import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
@@ -34,87 +29,81 @@ interface TemplateForm {
isDefault: boolean; isDefault: boolean;
} }
const defaultForm: TemplateForm = { name: "", type: "notes", content: "", isDefault: false }; const defaultForm: TemplateForm = {
name: "",
type: "notes",
content: "",
isDefault: false,
};
export default function TemplatesPage() { type InvoiceTemplate = RouterOutputs["invoiceTemplates"]["getAll"][number];
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(); interface TemplateListProps {
const { data: templates = [], isLoading } = api.invoiceTemplates.getAll.useQuery(); items: InvoiceTemplate[];
type: "notes" | "terms";
isLoading: boolean;
onCreate: (type: "notes" | "terms") => void;
onEdit: (template: InvoiceTemplate) => void;
onDelete: (id: string) => void;
}
const create = api.invoiceTemplates.create.useMutation({ function TemplateList({
onSuccess: () => { toast.success("Template created"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setForm(defaultForm); }, items,
onError: (e) => toast.error(e.message), type,
}); isLoading,
const update = api.invoiceTemplates.update.useMutation({ onCreate,
onSuccess: () => { toast.success("Template updated"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); }, onEdit,
onError: (e) => toast.error(e.message), onDelete,
}); }: TemplateListProps) {
const del = api.invoiceTemplates.delete.useMutation({ return (
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="space-y-3">
<div className="flex justify-end"> <div className="flex justify-end">
<Button size="sm" onClick={() => handleOpen(type)}> <Button size="sm" onClick={() => onCreate(type)}>
<Plus className="mr-1.5 h-3.5 w-3.5" /> New {type === "notes" ? "Notes" : "Terms"} Template <Plus className="mr-1.5 h-3.5 w-3.5" /> New{" "}
{type === "notes" ? "Notes" : "Terms"} Template
</Button> </Button>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm">Loading</div> <div className="text-muted-foreground py-8 text-center text-sm">
Loading...
</div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="text-muted-foreground py-8 text-center text-sm">
No {type} templates yet. No {type} templates yet.
</div> </div>
) : ( ) : (
items.map((t) => ( items.map((template) => (
<Card key={t.id}> <Card key={template.id}>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-medium">{t.name}</p> <p className="font-medium">{template.name}</p>
{t.isDefault && ( {template.isDefault && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
<Star className="mr-1 h-3 w-3" /> Default <Star className="mr-1 h-3 w-3" /> Default
</Badge> </Badge>
)} )}
</div> </div>
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap"> <p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap">
{t.content} {template.content}
</p> </p>
</div> </div>
<div className="flex flex-shrink-0 gap-1"> <div className="flex flex-shrink-0 gap-1">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(t)}> <Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onEdit(template)}
>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(t.id)}> <Button
variant="ghost"
size="sm"
className="text-destructive h-8 w-8 p-0"
onClick={() => onDelete(template.id)}
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -125,6 +114,77 @@ export default function TemplatesPage() {
)} )}
</div> </div>
); );
}
export default function TemplatesPage() {
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<TemplateForm>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [tab, setTab] = useState<"notes" | "terms">("notes");
const utils = api.useUtils();
const { data: templates = [], isLoading } =
api.invoiceTemplates.getAll.useQuery();
const create = api.invoiceTemplates.create.useMutation({
onSuccess: () => {
toast.success("Template created");
void utils.invoiceTemplates.getAll.invalidate();
setOpen(false);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message),
});
const update = api.invoiceTemplates.update.useMutation({
onSuccess: () => {
toast.success("Template updated");
void utils.invoiceTemplates.getAll.invalidate();
setOpen(false);
setEditId(null);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message),
});
const del = api.invoiceTemplates.delete.useMutation({
onSuccess: () => {
toast.success("Template deleted");
void utils.invoiceTemplates.getAll.invalidate();
setDeleteId(null);
},
onError: (e) => toast.error(e.message),
});
const handleOpen = (type: "notes" | "terms") => {
setEditId(null);
setForm({ ...defaultForm, type });
setOpen(true);
};
const handleEdit = (t: InvoiceTemplate) => {
setEditId(t.id);
setForm({
name: t.name,
type: t.type as "notes" | "terms",
content: t.content,
isDefault: t.isDefault,
});
setOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) {
toast.error("Name is required");
return;
}
if (!form.content.trim()) {
toast.error("Content is required");
return;
}
if (editId) update.mutate({ id: editId, ...form });
else create.mutate(form);
};
const notesTemplates = templates.filter((t) => t.type === "notes");
const termsTemplates = templates.filter((t) => t.type === "terms");
return ( return (
<div className="page-enter space-y-6 pb-6"> <div className="page-enter space-y-6 pb-6">
@@ -137,17 +197,33 @@ export default function TemplatesPage() {
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}> <Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes"> <TabsTrigger value="notes">
<FileText className="mr-1.5 h-4 w-4" /> Notes ({notesTemplates.length}) <FileText className="mr-1.5 h-4 w-4" /> Notes (
{notesTemplates.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="terms"> <TabsTrigger value="terms">
<FileText className="mr-1.5 h-4 w-4" /> Terms ({termsTemplates.length}) <FileText className="mr-1.5 h-4 w-4" /> Terms (
{termsTemplates.length})
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="notes" className="mt-4"> <TabsContent value="notes" className="mt-4">
<TemplateList items={notesTemplates} type="notes" /> <TemplateList
items={notesTemplates}
type="notes"
isLoading={isLoading}
onCreate={handleOpen}
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent> </TabsContent>
<TabsContent value="terms" className="mt-4"> <TabsContent value="terms" className="mt-4">
<TemplateList items={termsTemplates} type="terms" /> <TemplateList
items={termsTemplates}
type="terms"
isLoading={isLoading}
onCreate={handleOpen}
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -155,16 +231,29 @@ export default function TemplatesPage() {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{editId ? "Edit Template" : "New Template"}</DialogTitle> <DialogTitle>
{editId ? "Edit Template" : "New Template"}
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>Name *</Label> <Label>Name *</Label>
<Input value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Standard Payment Terms" /> <Input
value={form.name}
onChange={(e) =>
setForm((p) => ({ ...p, name: e.target.value }))
}
placeholder="e.g. Standard Payment Terms"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Type</Label> <Label>Type</Label>
<Tabs value={form.type} onValueChange={(v) => setForm((p) => ({ ...p, type: v as "notes" | "terms" }))}> <Tabs
value={form.type}
onValueChange={(v) =>
setForm((p) => ({ ...p, type: v as "notes" | "terms" }))
}
>
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes">Notes</TabsTrigger> <TabsTrigger value="notes">Notes</TabsTrigger>
<TabsTrigger value="terms">Terms</TabsTrigger> <TabsTrigger value="terms">Terms</TabsTrigger>
@@ -175,20 +264,36 @@ export default function TemplatesPage() {
<Label>Content *</Label> <Label>Content *</Label>
<Textarea <Textarea
value={form.content} value={form.content}
onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} onChange={(e) =>
setForm((p) => ({ ...p, content: e.target.value }))
}
placeholder="Template content…" placeholder="Template content…"
className="min-h-[120px]" className="min-h-[120px]"
/> />
</div> </div>
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.isDefault} onCheckedChange={(v) => setForm((p) => ({ ...p, isDefault: !!v }))} /> <Checkbox
checked={form.isDefault}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, isDefault: !!v }))
}
/>
<span className="text-sm">Set as default for {form.type}</span> <span className="text-sm">Set as default for {form.type}</span>
</label> </label>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="outline" onClick={() => setOpen(false)}>
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}> Cancel
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Create"} </Button>
<Button
onClick={handleSubmit}
disabled={create.isPending || update.isPending}
>
{create.isPending || update.isPending
? "Saving…"
: editId
? "Update"
: "Create"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -202,8 +307,14 @@ export default function TemplatesPage() {
<DialogDescription>This action cannot be undone.</DialogDescription> <DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button> <Button variant="outline" onClick={() => setDeleteId(null)}>
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}> Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && del.mutate({ id: deleteId })}
disabled={del.isPending}
>
{del.isPending ? "Deleting…" : "Delete"} {del.isPending ? "Deleting…" : "Delete"}
</Button> </Button>
</DialogFooter> </DialogFooter>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+63 -13
View File
@@ -6,26 +6,34 @@ import { Inter, Playfair_Display, Geist_Mono } from "next/font/google";
import { TRPCReactProvider } from "~/trpc/react"; import { TRPCReactProvider } from "~/trpc/react";
import { Toaster } from "~/components/ui/sonner"; import { Toaster } from "~/components/ui/sonner";
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider"; import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
import { AppearanceProvider } from "~/components/providers/appearance-provider";
import {
brand,
defaultBodyFontPreference,
defaultFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
} from "~/lib/branding";
import { UmamiScript } from "~/components/analytics/umami-script"; import { UmamiScript } from "~/components/analytics/umami-script";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "beenvoice - Invoicing Made Simple", title: `${brand.name} - Invoicing Made Simple`,
description: description: brand.tagline,
"Simple and efficient invoicing for freelancers and small businesses",
icons: [{ rel: "icon", url: "/favicon.ico" }], icons: [{ rel: "icon", url: "/favicon.ico" }],
}; };
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-sans", variable: "--font-inter",
display: "swap", display: "swap",
}); });
const playfair = Playfair_Display({ const playfair = Playfair_Display({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-heading", variable: "--font-playfair",
display: "swap", display: "swap",
}); });
@@ -42,20 +50,62 @@ export default function RootLayout({
<html <html
suppressHydrationWarning suppressHydrationWarning
lang="en" lang="en"
data-interface-theme={defaultInterfaceTheme}
data-font={defaultFontPreference}
data-body-font={defaultBodyFontPreference}
data-heading-font={defaultHeadingFontPreference}
data-radius={defaultRadiusPreference}
data-sidebar-style={defaultSidebarStyle}
data-color-mode="system"
data-color-theme="slate"
className={`${inter.variable} ${playfair.variable} ${geistMono.variable}`} className={`${inter.variable} ${playfair.variable} ${geistMono.variable}`}
> >
<head>
<script
id="appearance-init"
dangerouslySetInnerHTML={{
__html: `
try {
var defaults = {
interfaceTheme: "${defaultInterfaceTheme}",
fontPreference: "${defaultFontPreference}",
bodyFontPreference: "${defaultBodyFontPreference}",
headingFontPreference: "${defaultHeadingFontPreference}",
radiusPreference: "${defaultRadiusPreference}",
sidebarStyle: "${defaultSidebarStyle}",
colorMode: "system",
colorTheme: "slate"
};
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
var appearance = Object.assign(defaults, stored);
var root = document.documentElement;
root.dataset.interfaceTheme = appearance.interfaceTheme;
root.dataset.font = appearance.fontPreference;
root.dataset.bodyFont = appearance.bodyFontPreference || appearance.fontPreference;
root.dataset.headingFont = appearance.headingFontPreference || appearance.fontPreference;
root.dataset.radius = appearance.radiusPreference;
root.dataset.sidebarStyle = appearance.sidebarStyle;
root.dataset.colorMode = appearance.colorMode;
root.dataset.colorTheme = appearance.colorTheme;
if (appearance.colorMode === "dark") root.classList.add("dark");
if (appearance.customColor) root.style.setProperty("--custom-primary", appearance.customColor);
} catch {}
`,
}}
/>
</head>
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased"> <body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center"> <div className="brand-background pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div> <div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="w-[800px] h-[800px] bg-neutral-400/40 dark:bg-neutral-500/30 rounded-full blur-3xl animate-blob"></div> <div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/40 blur-3xl dark:bg-neutral-500/30"></div>
</div> </div>
<TRPCReactProvider> <TRPCReactProvider>
<AnimationPreferencesProvider> <AppearanceProvider>
<div className="relative z-10"> <AnimationPreferencesProvider>
{children} <div className="relative z-10">{children}</div>
</div> </AnimationPreferencesProvider>
</AnimationPreferencesProvider> </AppearanceProvider>
<Toaster /> <Toaster />
<UmamiScript /> <UmamiScript />
</TRPCReactProvider> </TRPCReactProvider>
+118 -66
View File
@@ -12,20 +12,24 @@ import {
BarChart3, BarChart3,
Rocket, Rocket,
} from "lucide-react"; } from "lucide-react";
import { brand } from "~/lib/branding";
import { env } from "~/env";
export default function HomePage() { export default function HomePage() {
const allowRegistration = env.DISABLE_SIGNUPS !== true;
return ( return (
<div className="min-h-screen relative overflow-x-hidden"> <div className="relative min-h-screen overflow-x-hidden">
<AuthRedirect /> <AuthRedirect />
{/* Blob Background for Homepage */} {/* Blob Background for Homepage */}
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center"> <div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div> <div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="w-[800px] h-[800px] bg-neutral-400/30 dark:bg-neutral-500/20 rounded-full blur-3xl animate-blob"></div> <div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="fixed top-4 left-4 right-4 z-50 m-4 rounded-2xl border border-border/60 bg-background/80 backdrop-blur-md"> <nav className="border-border/60 bg-background/80 fixed top-4 right-4 left-4 z-50 m-4 rounded-2xl border backdrop-blur-md">
<div className="mx-auto px-6"> <div className="mx-auto px-6">
<div className="flex h-16 items-center justify-between"> <div className="flex h-16 items-center justify-between">
<Logo /> <Logo />
@@ -53,11 +57,17 @@ export default function HomePage() {
Sign In Sign In
</Button> </Button>
</Link> </Link>
<Link href="/auth/register"> {allowRegistration && (
<Button size="sm" variant="default" className="rounded-xl px-6"> <Link href="/auth/register">
Get Started <Button
</Button> size="sm"
</Link> variant="default"
className="rounded-xl px-6"
>
Get Started
</Button>
</Link>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -67,42 +77,44 @@ export default function HomePage() {
<section className="relative pt-48 pb-32"> <section className="relative pt-48 pb-32">
<div className="container mx-auto px-4 text-center"> <div className="container mx-auto px-4 text-center">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<Badge className="bg-primary/10 text-primary border-primary/20 mb-8 border px-4 py-1 text-sm rounded-full"> <Badge className="bg-primary/10 text-primary border-primary/20 mb-8 rounded-full border px-4 py-1 text-sm">
<Zap className="mr-2 h-3.5 w-3.5" /> <Zap className="mr-2 h-3.5 w-3.5" />
Completely Free for Everyone Completely Free for Everyone
</Badge> </Badge>
<h1 className="text-foreground mb-8 text-6xl font-heading font-bold tracking-tight sm:text-7xl lg:text-8xl leading-tight"> <h1 className="text-foreground font-heading mb-8 text-6xl leading-tight font-bold tracking-tight sm:text-7xl lg:text-8xl">
Invoicing Made <br /> {brand.name} <br />
<span className="text-primary italic">Beautifully Simple.</span> <span className="text-primary italic">Beautifully Simple.</span>
</h1> </h1>
<p className="text-muted-foreground mx-auto mb-12 max-w-2xl text-xl leading-relaxed font-sans"> <p className="text-muted-foreground mx-auto mb-12 max-w-2xl font-sans text-xl leading-relaxed">
Create professional invoices, manage clients, and track payments with a tool that feels as good as it looks. {brand.tagline}
</p> </p>
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center"> <div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
<Link href="/auth/register"> {allowRegistration && (
<Button <Link href="/auth/register">
size="lg" <Button
className="h-14 px-10 text-lg rounded-2xl shadow-xl shadow-primary/20 hover:shadow-2xl hover:shadow-primary/30 transition-all duration-300" size="lg"
> className="shadow-primary/20 hover:shadow-primary/30 h-14 rounded-2xl px-10 text-lg shadow-xl transition-all duration-300 hover:shadow-2xl"
Start For Free >
<ArrowRight className="ml-2 h-5 w-5" /> Start For Free
</Button> <ArrowRight className="ml-2 h-5 w-5" />
</Link> </Button>
</Link>
)}
<a href="#features"> <a href="#features">
<Button <Button
variant="outline" variant="outline"
size="lg" size="lg"
className="h-14 px-10 text-lg rounded-2xl border-border/50 bg-background/50 hover:bg-background/80 backdrop-blur-sm" className="border-border/50 bg-background/50 hover:bg-background/80 h-14 rounded-2xl px-10 text-lg backdrop-blur-sm"
> >
Learn More Learn More
</Button> </Button>
</a> </a>
</div> </div>
<div className="mt-16 text-muted-foreground/80 flex flex-col items-center justify-center gap-2 text-sm sm:flex-row sm:gap-8"> <div className="text-muted-foreground/80 mt-16 flex flex-col items-center justify-center gap-2 text-sm sm:flex-row sm:gap-8">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Check className="text-primary h-4 w-4" /> <Check className="text-primary h-4 w-4" />
<span>No credit card required</span> <span>No credit card required</span>
@@ -121,11 +133,12 @@ export default function HomePage() {
</section> </section>
{/* Features Section */} {/* Features Section */}
<section id="features" className="py-24 relative"> <section id="features" className="relative py-24">
<div className="container mx-auto px-4 relative z-10"> <div className="relative z-10 container mx-auto px-4">
<div className="mb-20 text-center"> <div className="mb-20 text-center">
<h2 className="text-foreground mb-6 text-4xl font-heading font-bold sm:text-5xl"> <h2 className="text-foreground font-heading mb-6 text-4xl font-bold sm:text-5xl">
Everything you need to <span className="italic text-primary">thrive</span> Everything you need to{" "}
<span className="text-primary italic">thrive</span>
</h2> </h2>
<p className="text-muted-foreground mx-auto max-w-2xl text-lg"> <p className="text-muted-foreground mx-auto max-w-2xl text-lg">
Powerful features wrapped in a calm, focused interface. Powerful features wrapped in a calm, focused interface.
@@ -137,28 +150,46 @@ export default function HomePage() {
{ {
icon: Rocket, icon: Rocket,
title: "Quick Setup", title: "Quick Setup",
description: "Start creating invoices immediately. No complicated setup required.", description:
items: ["Simple client management", "Professional templates", "Easy invoice sending"] "Start creating invoices immediately. No complicated setup required.",
items: [
"Simple client management",
"Professional templates",
"Easy invoice sending",
],
}, },
{ {
icon: BarChart3, icon: BarChart3,
title: "Payment Tracking", title: "Payment Tracking",
description: "Keep track of invoice status and monitor your payments effortlessly.", description:
items: ["Invoice status tracking", "Payment history", "Overdue notifications"] "Keep track of invoice status and monitor your payments effortlessly.",
items: [
"Invoice status tracking",
"Payment history",
"Overdue notifications",
],
}, },
{ {
icon: Shield, icon: Shield,
title: "Professional Features", title: "Professional Features",
description: "Tools that make you look professional and get you paid faster.", description:
items: ["PDF generation", "Custom tax rates", "Professional numbering"] "Tools that make you look professional and get you paid faster.",
} items: [
"PDF generation",
"Custom tax rates",
"Professional numbering",
],
},
].map((feature, i) => ( ].map((feature, i) => (
<Card key={i} className="group hover:-translate-y-2 transition-transform duration-500 border-border/40 bg-background/60 backdrop-blur-xl"> <Card
key={i}
className="group border-border/40 bg-background/60 backdrop-blur-xl transition-transform duration-500 hover:-translate-y-2"
>
<CardContent className="p-8"> <CardContent className="p-8">
<div className="bg-primary/10 text-primary mb-6 inline-flex rounded-2xl p-4"> <div className="bg-primary/10 text-primary mb-6 inline-flex rounded-2xl p-4">
<feature.icon className="h-8 w-8" /> <feature.icon className="h-8 w-8" />
</div> </div>
<h3 className="text-foreground mb-4 text-2xl font-bold font-heading"> <h3 className="text-foreground font-heading mb-4 text-2xl font-bold">
{feature.title} {feature.title}
</h3> </h3>
<p className="text-muted-foreground mb-6 leading-relaxed"> <p className="text-muted-foreground mb-6 leading-relaxed">
@@ -166,8 +197,11 @@ export default function HomePage() {
</p> </p>
<ul className="space-y-3"> <ul className="space-y-3">
{feature.items.map((item, j) => ( {feature.items.map((item, j) => (
<li key={j} className="flex items-center gap-3 text-sm text-foreground/80"> <li
<div className="h-1.5 w-1.5 rounded-full bg-primary" /> key={j}
className="text-foreground/80 flex items-center gap-3 text-sm"
>
<div className="bg-primary h-1.5 w-1.5 rounded-full" />
{item} {item}
</li> </li>
))} ))}
@@ -180,42 +214,53 @@ export default function HomePage() {
</section> </section>
{/* Pricing Section */} {/* Pricing Section */}
<section id="pricing" className="py-24 relative overflow-hidden"> <section id="pricing" className="relative overflow-hidden py-24">
<div className="container mx-auto px-4 relative z-10"> <div className="relative z-10 container mx-auto px-4">
<div className="max-w-4xl mx-auto text-center mb-16"> <div className="mx-auto mb-16 max-w-4xl text-center">
<h2 className="text-5xl font-heading font-bold mb-6">Simple Pricing</h2> <h2 className="font-heading mb-6 text-5xl font-bold">
<p className="text-xl text-muted-foreground">Focus on your work, not on fees.</p> Simple Pricing
</h2>
<p className="text-muted-foreground text-xl">
Focus on your work, not on fees.
</p>
</div> </div>
<div className="max-w-md mx-auto"> <div className="mx-auto max-w-md">
<Card className="relative overflow-visible border-primary/50 shadow-2xl shadow-primary/5 bg-background/80 backdrop-blur-xl"> <Card className="border-primary/50 shadow-primary/5 bg-background/80 relative overflow-visible shadow-2xl backdrop-blur-xl">
<div className="absolute -top-4 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground px-6 py-1.5 rounded-full text-sm font-medium shadow-lg"> <div className="bg-primary text-primary-foreground absolute -top-4 left-1/2 -translate-x-1/2 rounded-full px-6 py-1.5 text-sm font-medium shadow-lg">
Forever Free Forever Free
</div> </div>
<CardContent className="p-10 text-center"> <CardContent className="p-10 text-center">
<div className="mb-2 text-6xl font-bold font-heading">$0</div> <div className="font-heading mb-2 text-6xl font-bold">$0</div>
<div className="text-muted-foreground mb-8">No credit card required.</div> <div className="text-muted-foreground mb-8">
No credit card required.
</div>
<div className="space-y-4 mb-10 text-left pl-8"> <div className="mb-10 space-y-4 pl-8 text-left">
{[ {[
"Unlimited Invoices", "Unlimited Invoices",
"Unlimited Clients", "Unlimited Clients",
"PDF Downloads", "PDF Downloads",
"Payment Tracking", "Payment Tracking",
"Email Support" "Email Support",
].map((item, i) => ( ].map((item, i) => (
<div key={i} className="flex items-center gap-3"> <div key={i} className="flex items-center gap-3">
<Check className="h-5 w-5 text-primary shrink-0" /> <Check className="text-primary h-5 w-5 shrink-0" />
<span className="text-foreground/90">{item}</span> <span className="text-foreground/90">{item}</span>
</div> </div>
))} ))}
</div> </div>
<Link href="/auth/register" className="block"> {allowRegistration && (
<Button size="lg" className="w-full text-lg h-12 rounded-xl"> <Link href="/auth/register" className="block">
Get Started <Button
</Button> size="lg"
</Link> className="h-12 w-full rounded-xl text-lg"
>
Get Started
</Button>
</Link>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -223,20 +268,27 @@ export default function HomePage() {
</section> </section>
{/* Footer */} {/* Footer */}
<footer className="border-t border-border/40 bg-background/50 backdrop-blur-sm py-12 mt-12"> <footer className="border-border/40 bg-background/50 mt-12 border-t py-12 backdrop-blur-sm">
<div className="container mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-6"> <div className="container mx-auto flex flex-col items-center justify-between gap-6 px-6 md:flex-row">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Logo size="sm" /> <Logo size="sm" />
<span className="text-sm text-muted-foreground">© 2024 beenvoice</span> <span className="text-muted-foreground text-sm">
© 2024 beenvoice
</span>
</div> </div>
<div className="flex gap-8 text-sm text-muted-foreground"> <div className="text-muted-foreground flex gap-8 text-sm">
<a href="#" className="hover:text-foreground transition-colors">Privacy</a> <a href="#" className="hover:text-foreground transition-colors">
<a href="#" className="hover:text-foreground transition-colors">Terms</a> Privacy
<a href="#" className="hover:text-foreground transition-colors">Contact</a> </a>
<a href="#" className="hover:text-foreground transition-colors">
Terms
</a>
<a href="#" className="hover:text-foreground transition-colors">
Contact
</a>
</div> </div>
</div> </div>
</footer> </footer>
</div> </div>
); );
} }
+42 -9
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { brand } from "~/lib/branding";
import { useAppearance } from "~/components/providers/appearance-provider";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
interface LogoProps { interface LogoProps {
@@ -10,6 +12,9 @@ interface LogoProps {
} }
export function Logo({ className, size = "md", animated = true }: LogoProps) { export function Logo({ className, size = "md", animated = true }: LogoProps) {
const appearance = useAppearance();
const logoText = appearance.brandLogoText || brand.logoText;
const icon = appearance.brandIcon || brand.icon;
const sizeClasses = { const sizeClasses = {
sm: "text-base", sm: "text-base",
md: "text-xl", md: "text-xl",
@@ -19,7 +24,15 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
}; };
if (!animated) { if (!animated) {
return <LogoContent className={className} size={size} sizeClasses={sizeClasses} />; return (
<LogoContent
className={className}
size={size}
sizeClasses={sizeClasses}
logoText={logoText}
icon={icon}
/>
);
} }
return ( return (
@@ -27,7 +40,11 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 0.1, ease: "easeOut" }} transition={{ duration: 0.1, ease: "easeOut" }}
className={cn("flex items-center font-mono", sizeClasses[size], className)} className={cn(
"flex items-center font-mono",
sizeClasses[size],
className,
)}
> >
<motion.span <motion.span
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -35,7 +52,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }} transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
className="text-primary font-bold tracking-tight" className="text-primary font-bold tracking-tight"
> >
$ {icon}
</motion.span> </motion.span>
{size !== "icon" && ( {size !== "icon" && (
<> <>
@@ -51,7 +68,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.04, duration: 0.05, ease: "easeOut" }} transition={{ delay: 0.04, duration: 0.05, ease: "easeOut" }}
className="text-foreground font-bold tracking-tight" className="text-foreground font-bold tracking-tight"
> >
been {logoText.slice(0, Math.ceil(logoText.length / 2))}
</motion.span> </motion.span>
<motion.span <motion.span
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -59,7 +76,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.06, duration: 0.05, ease: "easeOut" }} transition={{ delay: 0.06, duration: 0.05, ease: "easeOut" }}
className="text-foreground/70 font-bold tracking-tight" className="text-foreground/70 font-bold tracking-tight"
> >
voice {logoText.slice(Math.ceil(logoText.length / 2))}
</motion.span> </motion.span>
</> </>
)} )}
@@ -71,19 +88,35 @@ function LogoContent({
className, className,
size, size,
sizeClasses, sizeClasses,
logoText,
icon,
}: { }: {
className?: string; className?: string;
size: "sm" | "md" | "lg" | "xl" | "icon"; size: "sm" | "md" | "lg" | "xl" | "icon";
sizeClasses: Record<string, string>; sizeClasses: Record<string, string>;
logoText: string;
icon: string;
}) { }) {
return ( return (
<div className={cn("flex items-center font-mono", sizeClasses[size], className)}> <div
<span className="text-primary font-bold tracking-tight">$</span> className={cn(
"flex items-center font-mono",
sizeClasses[size],
className,
)}
>
<span className="text-primary font-bold tracking-tight">
{icon}
</span>
{size !== "icon" && ( {size !== "icon" && (
<> <>
<span className="inline-block w-1"></span> <span className="inline-block w-1"></span>
<span className="text-foreground font-bold tracking-tight">been</span> <span className="text-foreground font-bold tracking-tight">
<span className="text-foreground/70 font-bold tracking-tight">voice</span> {logoText.slice(0, Math.ceil(logoText.length / 2))}
</span>
<span className="text-foreground/70 font-bold tracking-tight">
{logoText.slice(Math.ceil(logoText.length / 2))}
</span>
</> </>
)} )}
</div> </div>
-504
View File
@@ -1,504 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
import { Separator } from "~/components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { toast } from "sonner";
import { format } from "date-fns";
import {
FileText,
User,
DollarSign,
Trash2,
Download,
Send,
Clock,
MapPin,
Mail,
Phone,
AlertCircle,
} from "lucide-react";
import Link from "next/link";
import { generateInvoicePDF } from "~/lib/pdf-export";
import { Skeleton } from "~/components/ui/skeleton";
interface InvoiceViewProps {
invoiceId: string;
}
const statusIconConfig = {
draft: FileText,
sent: Send,
paid: DollarSign,
overdue: AlertCircle,
} as const;
export function InvoiceView({ invoiceId }: InvoiceViewProps) {
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isExportingPDF, setIsExportingPDF] = useState(false);
// Fetch invoice data
const {
data: invoice,
isLoading,
refetch,
} = api.invoices.getById.useQuery({ id: invoiceId });
// Delete mutation
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
toast.success("Invoice deleted successfully");
setDeleteDialogOpen(false);
router.push("/dashboard/invoices");
},
onError: (error) => {
toast.error(error.message ?? "Failed to delete invoice");
},
});
// Update status mutation
const updateStatus = api.invoices.updateStatus.useMutation({
onSuccess: () => {
toast.success("Status updated successfully");
void refetch();
},
onError: (error) => {
toast.error(error.message ?? "Failed to update status");
},
});
const handleDelete = () => {
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
deleteInvoice.mutate({ id: invoiceId });
};
const handleStatusUpdate = (newStatus: "draft" | "sent" | "paid") => {
updateStatus.mutate({ id: invoiceId, status: newStatus });
};
const handlePDFExport = async () => {
if (!invoice) return;
setIsExportingPDF(true);
try {
await generateInvoicePDF(invoice);
toast.success("PDF exported successfully");
} catch (error) {
console.error("PDF export error:", error);
toast.error("Failed to export PDF. Please try again.");
} finally {
setIsExportingPDF(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
const formatDate = (date: Date) => {
return format(new Date(date), "MMM dd, yyyy");
};
const isOverdue =
invoice &&
new Date(invoice.dueDate) < new Date() &&
invoice.status !== "paid";
if (isLoading) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<div className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</CardContent>
</Card>
</div>
);
}
if (!invoice) {
return (
<div className="py-12 text-center">
<FileText className="text-muted mx-auto mb-4 h-12 w-12" />
<h3 className="text-foreground mb-2 text-lg font-medium">
Invoice not found
</h3>
<p className="text-muted mb-4">
The invoice you&apos;re looking for doesn&apos;t exist or has been
deleted.
</p>
<Button asChild>
<Link href="/dashboard/invoices">Back to Invoices</Link>
</Button>
</div>
);
}
const StatusIcon =
statusIconConfig[invoice.status as keyof typeof statusIconConfig];
return (
<div className="space-y-6">
{/* Status Alert */}
{isOverdue && (
<Card className="border-destructive/20 bg-destructive/10">
<CardContent className="p-4">
<div className="text-destructive flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
<span className="font-medium">This invoice is overdue</span>
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 lg:col-span-2">
{/* Invoice Header Card */}
<Card className="bg-card border-border border">
<CardContent>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1 space-y-4">
<div className="flex items-center gap-3">
<div className="bg-primary/10 flex-shrink-0 p-2">
<FileText className="text-primary h-6 w-6" />
</div>
<div className="min-w-0">
<h2 className="text-foreground truncate text-2xl font-bold">
{invoice.invoiceNumber}
</h2>
<p className="text-muted-foreground">
Professional Invoice
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6 text-sm">
<div>
<span className="text-muted-foreground">Issue Date</span>
<p className="text-foreground font-medium">
{formatDate(invoice.issueDate)}
</p>
</div>
<div>
<span className="text-muted-foreground">Due Date</span>
<p className="text-foreground font-medium">
{formatDate(invoice.dueDate)}
</p>
</div>
</div>
</div>
<div className="flex flex-row items-center justify-between gap-3 sm:flex-col sm:items-end sm:text-right">
<div>
<StatusBadge
status={invoice.status as StatusType}
className="px-3 py-1 text-sm font-medium"
>
<StatusIcon className="mr-1 h-3 w-3" />
</StatusBadge>
<div className="text-primary mt-1 text-2xl font-bold sm:text-3xl">
{formatCurrency(invoice.totalAmount)}
</div>
</div>
<Button
onClick={handlePDFExport}
disabled={isExportingPDF}
variant="default"
className="transform-none flex-shrink-0"
>
{isExportingPDF ? (
<>
<div className="mr-2 h-4 w-4 animate-spin border-2 border-white border-t-transparent" />
Generating PDF...
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Download PDF
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Client Information */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-primary flex items-center gap-2">
<User className="h-5 w-5" />
Bill To
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="text-foreground text-lg font-semibold">
{invoice.client?.name}
</h3>
</div>
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
{invoice.client?.email && (
<div className="text-muted-foreground flex items-center gap-2">
<Mail className="text-muted-foreground h-4 w-4" />
{invoice.client.email}
</div>
)}
{invoice.client?.phone && (
<div className="text-muted-foreground flex items-center gap-2">
<Phone className="text-muted-foreground h-4 w-4" />
{invoice.client.phone}
</div>
)}
{(invoice.client?.addressLine1 ??
invoice.client?.city ??
invoice.client?.state) && (
<div className="text-muted-foreground flex items-start gap-2 md:col-span-2">
<MapPin className="text-muted-foreground mt-0.5 h-4 w-4 flex-shrink-0" />
<div>
{invoice.client?.addressLine1 && (
<div>{invoice.client.addressLine1}</div>
)}
{invoice.client?.addressLine2 && (
<div>{invoice.client.addressLine2}</div>
)}
{(invoice.client?.city ??
invoice.client?.state ??
invoice.client?.postalCode) && (
<div>
{[
invoice.client?.city,
invoice.client?.state,
invoice.client?.postalCode,
]
.filter(Boolean)
.join(", ")}
</div>
)}
{invoice.client?.country && (
<div>{invoice.client.country}</div>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Invoice Items */}
<Card className="bg-secondary border-border border">
<CardHeader>
<CardTitle className="text-primary flex items-center gap-2">
<Clock className="h-5 w-5" />
Invoice Items
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{invoice.items?.map((item, index) => (
<div
key={item.id || index}
className="bg-background flex flex-col gap-1 rounded-lg p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 flex-1">
<div className="text-foreground font-medium break-words">
{item.description}
</div>
<div className="text-muted-foreground mt-0.5 text-sm">
{formatDate(item.date)} &middot; {item.hours}h @{" "}
{formatCurrency(item.rate)}/hr
</div>
</div>
<div className="text-foreground flex-shrink-0 font-medium sm:text-right">
{formatCurrency(item.amount)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Notes */}
{invoice.notes && (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-primary">Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground whitespace-pre-wrap">
{invoice.notes}
</p>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Status Actions */}
<Card className="bg-secondary border-border border">
<CardHeader>
<CardTitle className="text-primary">Status Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{invoice.status === "draft" && (
<Button
onClick={() => handleStatusUpdate("sent")}
disabled={updateStatus.isPending}
className="w-full"
>
<Send className="mr-2 h-4 w-4" />
Mark as Sent
</Button>
)}
{invoice.status === "sent" && (
<Button
onClick={() => handleStatusUpdate("paid")}
disabled={updateStatus.isPending}
className="w-full"
>
<DollarSign className="mr-2 h-4 w-4" />
Mark as Paid
</Button>
)}
{invoice.status === "overdue" && (
<Button
onClick={() => handleStatusUpdate("paid")}
disabled={updateStatus.isPending}
className="w-full"
>
<DollarSign className="mr-2 h-4 w-4" />
Mark as Paid
</Button>
)}
{invoice.status === "paid" && (
<div className="py-4 text-center">
<DollarSign className="text-primary mx-auto mb-2 h-8 w-8" />
<p className="text-primary font-medium">Invoice Paid</p>
</div>
)}
</CardContent>
</Card>
{/* Invoice Summary */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-primary">Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal</span>
<span className="text-foreground font-medium">
{formatCurrency(invoice.totalAmount)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax</span>
<span className="text-foreground font-medium">$0.00</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-bold">
<span className="text-foreground">Total</span>
<span className="text-primary">
{formatCurrency(invoice.totalAmount)}
</span>
</div>
</div>
<div className="border-border border-t pt-4 text-center">
<p className="text-muted-foreground text-sm">
{invoice.items?.length ?? 0} item
{invoice.items?.length !== 1 ? "s" : ""}
</p>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="bg-card border-destructive/20 border">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
</CardHeader>
<CardContent>
<Button
onClick={handleDelete}
variant="destructive"
className="w-full"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Invoice
</Button>
</CardContent>
</Card>
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="bg-card border-border border">
<DialogHeader>
<DialogTitle className="text-foreground text-xl font-bold">
Delete Invoice
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Are you sure you want to delete this invoice? This action cannot
be undone and will permanently remove the invoice and all its
data.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={deleteInvoice.isPending}
className="bg-destructive hover:bg-destructive/90"
>
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+9 -10
View File
@@ -96,7 +96,7 @@ export function EmailComposer({
content: customMessage, content: customMessage,
immediatelyRender: false, immediatelyRender: false,
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
onCustomMessageChange?.(editor.getHTML()); onCustomMessageChange?.(editor.isEmpty ? "" : editor.getHTML());
}, },
editorProps: { editorProps: {
attributes: { attributes: {
@@ -109,7 +109,7 @@ export function EmailComposer({
// Update editor content when customMessage prop changes // Update editor content when customMessage prop changes
useEffect(() => { useEffect(() => {
if (editor && customMessage !== undefined) { if (editor && customMessage !== undefined) {
const currentContent = editor.getHTML(); const currentContent = editor.isEmpty ? "" : editor.getHTML();
if (currentContent !== customMessage) { if (currentContent !== customMessage) {
editor.commands.setContent(customMessage); editor.commands.setContent(customMessage);
} }
@@ -133,9 +133,9 @@ export function EmailComposer({
if (!editor) { if (!editor) {
return ( return (
<div className="bg-muted flex h-[200px] items-center justify-center border"> <div className="bg-muted flex h-[200px] items-center justify-center border">
<div className="text-center"> <div className="text-center">
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin border-2 border-t-transparent"></div> <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">Loading editor...</p> <p className="text-muted-foreground text-sm">Loading editor...</p>
</div> </div>
</div> </div>
@@ -145,7 +145,7 @@ export function EmailComposer({
return ( return (
<div className={className}> <div className={className}>
{/* Email Headers */} {/* Email Headers */}
<div className="bg-muted/20 space-y-4 border p-4"> <div className="bg-muted/20 space-y-4 border p-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="from-email" className="text-sm font-medium"> <Label htmlFor="from-email" className="text-sm font-medium">
@@ -222,16 +222,15 @@ export function EmailComposer({
{onCustomMessageChange && ( {onCustomMessageChange && (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label className="text-sm font-medium"> <Label className="text-sm font-medium">Email Note (Optional)</Label>
Custom Message (Optional)
</Label>
<p className="text-muted-foreground mb-2 text-xs"> <p className="text-muted-foreground mb-2 text-xs">
This message will appear between the greeting and invoice summary This appears only in the email body and is not added to the
invoice PDF.
</p> </p>
</div> </div>
{/* Editor Toolbar */} {/* Editor Toolbar */}
<div className="bg-muted/20 flex flex-wrap items-center gap-1 border p-2"> <div className="bg-muted/20 flex flex-wrap items-center gap-1 border p-2">
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive("bold")} isActive={editor.isActive("bold")}
+10 -6
View File
@@ -17,6 +17,7 @@ interface EmailPreviewProps {
taxRate: number; taxRate: number;
status?: string; status?: string;
totalAmount?: number; totalAmount?: number;
currency?: string | null;
client?: { client?: {
name: string; name: string;
email: string | null; email: string | null;
@@ -27,8 +28,11 @@ interface EmailPreviewProps {
}; };
items?: Array<{ items?: Array<{
id: string; id: string;
date?: Date;
description?: string;
hours: number; hours: number;
rate: number; rate: number;
amount?: number;
}>; }>;
}; };
className?: string; className?: string;
@@ -66,7 +70,7 @@ export function EmailPreview({
status: invoice.status ?? "draft", status: invoice.status ?? "draft",
totalAmount: invoice.totalAmount ?? calculateTotal(), totalAmount: invoice.totalAmount ?? calculateTotal(),
taxRate: invoice.taxRate, taxRate: invoice.taxRate,
notes: null, currency: invoice.currency,
client: { client: {
name: invoice.client?.name ?? "Client", name: invoice.client?.name ?? "Client",
email: invoice.client?.email ?? null, email: invoice.client?.email ?? null,
@@ -74,11 +78,11 @@ export function EmailPreview({
business: invoice.business ?? null, business: invoice.business ?? null,
items: items:
invoice.items?.map((item) => ({ invoice.items?.map((item) => ({
date: new Date(), date: item.date ?? new Date(),
description: "Service", description: item.description ?? "Service",
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.hours * item.rate, amount: item.amount ?? item.hours * item.rate,
})) ?? [], })) ?? [],
}, },
customContent: content, customContent: content,
@@ -95,7 +99,7 @@ export function EmailPreview({
return ( return (
<div className={className}> <div className={className}>
{/* Email Headers */} {/* Email Headers */}
<div className="bg-muted/20 mb-4 space-y-3 p-4"> <div className="bg-muted/20 mb-4 space-y-3 p-4">
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-3"> <div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-3">
<div> <div>
<span className="text-muted-foreground block text-xs font-medium"> <span className="text-muted-foreground block text-xs font-medium">
@@ -142,7 +146,7 @@ export function EmailPreview({
{/* Email Content */} {/* Email Content */}
{emailTemplate ? ( {emailTemplate ? (
<div className=" border bg-gray-50 p-1 shadow-sm"> <div className="border bg-gray-50 p-1 shadow-sm">
<iframe <iframe
srcDoc={emailTemplate.html} srcDoc={emailTemplate.html}
className="h-[700px] w-full rounded border-0" className="h-[700px] w-full rounded border-0"
+294 -76
View File
@@ -15,13 +15,24 @@ import {
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { DatePicker } from "~/components/ui/date-picker"; import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
import { PageHeader } from "~/components/layout/page-header"; import { PageHeader } from "~/components/layout/page-header";
import { InvoiceLineItems } from "./invoice-line-items"; import { InvoiceLineItems } from "./invoice-line-items";
import { InvoiceCalendarView } from "./invoice-calendar-view"; import { InvoiceCalendarView } from "./invoice-calendar-view";
import { EmailPreview } from "./email-preview";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Save, Calendar as CalendarIcon, Tag, User, List, FileText, ChevronDown } from "lucide-react"; import {
Save,
Calendar as CalendarIcon,
Tag,
User,
List,
FileText,
ChevronDown,
Mail,
} from "lucide-react";
import { SUPPORTED_CURRENCIES } from "~/lib/currency"; import { SUPPORTED_CURRENCIES } from "~/lib/currency";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { import {
@@ -49,7 +60,7 @@ interface InvoiceFormProps {
function InvoiceFormSkeleton() { function InvoiceFormSkeleton() {
return ( return (
<div className="space-y-6 pb-32"> <div className="space-y-6 pb-8">
<PageHeader <PageHeader
title="Loading..." title="Loading..."
description="Loading invoice form" description="Loading invoice form"
@@ -65,6 +76,23 @@ function InvoiceFormSkeleton() {
); );
} }
function getDefaultHourlyRate(value: unknown) {
if (typeof value !== "object" || value === null) return null;
const rate = (value as { defaultHourlyRate?: unknown }).defaultHourlyRate;
return typeof rate === "number" ? rate : null;
}
function plainTextToHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\n/g, "<br>");
}
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter(); const router = useRouter();
const utils = api.useUtils(); const utils = api.useUtils();
@@ -72,12 +100,14 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
// State // State
const [formData, setFormData] = useState<InvoiceFormData>({ const [formData, setFormData] = useState<InvoiceFormData>({
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`, invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
invoicePrefix: "#",
businessId: "", businessId: "",
clientId: "", clientId: "",
issueDate: new Date(), issueDate: new Date(),
dueDate: new Date(), dueDate: new Date(),
status: "draft", status: "draft",
notes: "", notes: "",
emailMessage: "",
taxRate: 0, taxRate: 0,
currency: "USD", currency: "USD",
defaultHourlyRate: null, defaultHourlyRate: null,
@@ -97,11 +127,14 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState("details"); const [activeTab, setActiveTab] = useState("details");
const [previewTab, setPreviewTab] = useState("pdf");
// Queries (Same as before) // Queries (Same as before)
const { data: clients, isLoading: loadingClients } = const { data: clients, isLoading: loadingClients } =
api.clients.getAll.useQuery(); api.clients.getAll.useQuery();
const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({ type: "notes" }); const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({
type: "notes",
});
const { data: businesses, isLoading: loadingBusinesses } = const { data: businesses, isLoading: loadingBusinesses } =
api.businesses.getAll.useQuery(); api.businesses.getAll.useQuery();
const { data: existingInvoice, isLoading: loadingInvoice } = const { data: existingInvoice, isLoading: loadingInvoice } =
@@ -126,26 +159,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) { if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
// ... (Mapping logic same as before) // ... (Mapping logic same as before)
const mappedItems: InvoiceItem[] = const mappedItems: InvoiceItem[] =
existingInvoice.items existingInvoice.items?.map((item) => ({
?.map((item) => ({ id: crypto.randomUUID(),
id: crypto.randomUUID(), date: new Date(item.date),
date: new Date(item.date), description: item.description,
description: item.description, hours: item.hours,
hours: item.hours, rate: item.rate,
rate: item.rate, amount: item.amount,
amount: item.amount, })) || [];
}))
.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
) || [];
setFormData({ setFormData({
invoiceNumber: existingInvoice.invoiceNumber, invoiceNumber: existingInvoice.invoiceNumber,
invoicePrefix: existingInvoice.invoicePrefix ?? "#",
businessId: existingInvoice.businessId ?? "", businessId: existingInvoice.businessId ?? "",
clientId: existingInvoice.clientId, clientId: existingInvoice.clientId,
issueDate: new Date(existingInvoice.issueDate), issueDate: new Date(existingInvoice.issueDate),
dueDate: new Date(existingInvoice.dueDate), dueDate: new Date(existingInvoice.dueDate),
status: existingInvoice.status as "draft" | "sent" | "paid", status: existingInvoice.status as "draft" | "sent" | "paid",
notes: existingInvoice.notes ?? "", notes: existingInvoice.notes ?? "",
emailMessage: existingInvoice.emailMessage ?? "",
taxRate: existingInvoice.taxRate, taxRate: existingInvoice.taxRate,
currency: existingInvoice.currency ?? "USD", currency: existingInvoice.currency ?? "USD",
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null, defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
@@ -186,6 +217,55 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const total = subtotal + taxAmount; const total = subtotal + taxAmount;
return { subtotal, taxAmount, total }; return { subtotal, taxAmount, total };
}, [formData.items, formData.taxRate]); }, [formData.items, formData.taxRate]);
const emailPreviewMessage = React.useMemo(
() => plainTextToHtml(formData.emailMessage.trim()),
[formData.emailMessage],
);
const pdfPreviewInput = React.useMemo(
() => ({
invoiceNumber: formData.invoiceNumber,
invoicePrefix: formData.invoicePrefix,
businessId: formData.businessId || "",
clientId: formData.clientId,
issueDate: formData.issueDate,
dueDate: formData.dueDate,
status: formData.status,
notes: formData.notes,
emailMessage: formData.emailMessage,
taxRate: formData.taxRate,
currency: formData.currency,
items: formData.items.map((item) => ({
date: item.date,
description: item.description || "Service",
hours: item.hours,
rate: item.rate,
})),
}),
[formData],
);
const { data: pdfPreview, isFetching: pdfPreviewLoading } =
api.invoices.previewPdf.useQuery(pdfPreviewInput, {
enabled:
activeTab === "preview" &&
previewTab === "pdf" &&
Boolean(formData.clientId) &&
formData.items.length > 0 &&
formData.items.every((item) => item.description.trim() !== ""),
refetchOnWindowFocus: false,
staleTime: 0,
});
const selectedClient = React.useMemo(
() => clients?.find((client) => client.id === formData.clientId),
[clients, formData.clientId],
);
const selectedBusiness = React.useMemo(
() =>
businesses?.find((business) => business.id === formData.businessId) ??
businesses?.find((business) => business.isDefault),
[businesses, formData.businessId],
);
// Handlers (addItem, updateItem etc. - same as before) // Handlers (addItem, updateItem etc. - same as before)
const addItem = (date?: unknown) => { const addItem = (date?: unknown) => {
@@ -231,32 +311,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}), }),
})); }));
}; };
const moveItemUp = (idx: number) => {
if (idx === 0) return;
setFormData((prev) => {
const newItems = [...prev.items];
if (newItems[idx] && newItems[idx - 1]) {
const temp = newItems[idx - 1]!;
newItems[idx - 1] = newItems[idx];
newItems[idx] = temp;
}
return { ...prev, items: newItems };
});
};
const moveItemDown = (idx: number) => {
if (idx === formData.items.length - 1) return;
setFormData((prev) => {
const newItems = [...prev.items];
if (newItems[idx] && newItems[idx + 1]) {
const temp = newItems[idx + 1]!;
newItems[idx + 1] = newItems[idx];
newItems[idx] = temp;
}
return { ...prev, items: newItems };
});
};
const reorderItems = (newItems: InvoiceItem[]) =>
setFormData((prev) => ({ ...prev, items: newItems }));
const createInvoice = api.invoices.create.useMutation({ const createInvoice = api.invoices.create.useMutation({
onSuccess: (inv) => { onSuccess: (inv) => {
@@ -333,25 +387,23 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
try { try {
const payload = { const payload = {
invoiceNumber: formData.invoiceNumber, invoiceNumber: formData.invoiceNumber,
invoicePrefix: formData.invoicePrefix,
businessId: formData.businessId || "", businessId: formData.businessId || "",
clientId: formData.clientId, clientId: formData.clientId,
issueDate: formData.issueDate, issueDate: formData.issueDate,
dueDate: formData.dueDate, dueDate: formData.dueDate,
status: formData.status, status: formData.status,
notes: formData.notes, notes: formData.notes,
emailMessage: formData.emailMessage,
taxRate: formData.taxRate, taxRate: formData.taxRate,
currency: formData.currency, currency: formData.currency,
items: formData.items items: formData.items.map((i) => ({
.sort( date: i.date,
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), description: i.description,
) hours: i.hours,
.map((i) => ({ rate: i.rate,
date: i.date, amount: i.hours * i.rate,
description: i.description, })),
hours: i.hours,
rate: i.rate,
amount: i.hours * i.rate,
})),
}; };
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined) if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
await updateInvoice.mutateAsync({ id: invoiceId, ...payload }); await updateInvoice.mutateAsync({ id: invoiceId, ...payload });
@@ -382,7 +434,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
return ( return (
<> <>
<div className="page-enter space-y-6 pb-32"> <div className="page-enter space-y-6 pb-8">
<PageHeader <PageHeader
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"} title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
description="Manage your invoice" description="Manage your invoice"
@@ -405,7 +457,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}> <Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
{/* TAB SELECTOR: w-full, p-1, visible background */} {/* TAB SELECTOR: w-full, p-1, visible background */}
<TabsList className="bg-muted grid h-auto w-full grid-cols-3 rounded-xl p-1"> <TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1">
<TabsTrigger <TabsTrigger
value="details" value="details"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm" className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
@@ -424,6 +476,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
> >
Timesheet Timesheet
</TabsTrigger> </TabsTrigger>
<TabsTrigger
value="preview"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Preview
</TabsTrigger>
</TabsList> </TabsList>
{/* DETAILS TAB */} {/* DETAILS TAB */}
@@ -431,7 +489,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
value="details" value="details"
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2" className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2"
> >
<Card className="h-fit"> <Card className="h-full">
<CardHeader> <CardHeader>
<CardTitle className="flex gap-2 text-base"> <CardTitle className="flex gap-2 text-base">
<User className="h-4 w-4" /> Client Details <User className="h-4 w-4" /> Client Details
@@ -448,18 +506,20 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const currentBusiness = businesses?.find( const currentBusiness = businesses?.find(
(b) => b.id === formData.businessId, (b) => b.id === formData.businessId,
); );
const clientRate = const clientRate = getDefaultHourlyRate(selectedClient);
selectedClient && "defaultHourlyRate" in selectedClient
? selectedClient.defaultHourlyRate
: null;
const businessRate = const businessRate =
currentBusiness && "defaultHourlyRate" in currentBusiness getDefaultHourlyRate(currentBusiness);
? currentBusiness.defaultHourlyRate updateField(
: null; "defaultHourlyRate",
updateField("defaultHourlyRate", (clientRate ?? businessRate ?? 0) as number); clientRate ?? businessRate ?? 0,
);
// Auto-fill currency from client // Auto-fill currency from client
if (selectedClient && "currency" in selectedClient && selectedClient.currency) { if (
updateField("currency", selectedClient.currency as string); selectedClient &&
"currency" in selectedClient &&
selectedClient.currency
) {
updateField("currency", selectedClient.currency);
} }
}} }}
> >
@@ -496,10 +556,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardContent> </CardContent>
</Card> </Card>
<Card className="h-fit"> <Card className="h-full">
<CardHeader> <CardHeader>
<CardTitle className="flex gap-2 text-base"> <CardTitle className="flex gap-2 text-base">
<Tag className="h-4 w-4" /> Invoice Config <Tag className="h-4 w-4" /> Invoice Settings
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -525,6 +585,30 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[96px_1fr] sm:gap-4">
<div className="space-y-2">
<Label>Prefix</Label>
<Input
value={formData.invoicePrefix}
onChange={(e) =>
updateField("invoicePrefix", e.target.value)
}
placeholder="#"
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>Invoice Number</Label>
<Input
value={formData.invoiceNumber}
onChange={(e) =>
updateField("invoiceNumber", e.target.value)
}
placeholder="INV-20260428-000001"
className="w-full font-mono"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Tax Rate</Label> <Label>Tax Rate</Label>
@@ -589,17 +673,38 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardContent> </CardContent>
</Card> </Card>
{/* Notes card — spans both columns */} <Card className="h-fit">
<Card className="h-fit lg:col-span-2">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-base"> <CardTitle className="flex items-center justify-between gap-2 text-base">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<FileText className="h-4 w-4" /> Notes <Mail className="h-4 w-4" /> Email Message
</span>
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={formData.emailMessage}
onChange={(e) => updateField("emailMessage", e.target.value)}
placeholder="Add a note that appears only in the email body..."
className="min-h-[140px]"
/>
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-base">
<span className="flex items-center gap-2">
<FileText className="h-4 w-4" /> Invoice Notes
</span> </span>
{noteTemplates && noteTemplates.length > 0 && ( {noteTemplates && noteTemplates.length > 0 && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs"> <Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
>
Use template <ChevronDown className="h-3 w-3" /> Use template <ChevronDown className="h-3 w-3" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -621,8 +726,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Textarea <Textarea
value={formData.notes} value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)} onChange={(e) => updateField("notes", e.target.value)}
placeholder="Add notes, payment terms, or other information for the client…" placeholder="Add notes, payment terms, or other information for the invoice/PDF..."
className="min-h-[100px]" className="min-h-[140px]"
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -674,9 +779,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
onAddItem={addItem} onAddItem={addItem}
onRemoveItem={removeItem} onRemoveItem={removeItem}
onUpdateItem={updateItem} onUpdateItem={updateItem}
onMoveUp={moveItemUp}
onMoveDown={moveItemDown}
onReorderItems={reorderItems}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -704,6 +806,122 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent
value="preview"
className="mt-6 focus-visible:outline-none"
>
<Tabs
value={previewTab}
onValueChange={setPreviewTab}
className="w-full"
>
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1">
<TabsTrigger
value="pdf"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
PDF
</TabsTrigger>
<TabsTrigger
value="email"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Email
</TabsTrigger>
</TabsList>
<TabsContent value="pdf" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="flex gap-2">
<FileText className="h-5 w-5" /> PDF Preview
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="bg-muted/20 h-[760px] overflow-hidden border-t">
{!formData.clientId ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Select a client to generate the PDF preview.
</div>
) : formData.items.some(
(item) => item.description.trim() === "",
) ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Add descriptions for all line items to generate the
PDF preview.
</div>
) : pdfPreviewLoading && !pdfPreview ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Generating server PDF preview...
</div>
) : pdfPreview ? (
<iframe
title="Server-generated PDF preview"
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
className="h-full w-full border-0"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
PDF preview will appear here.
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="email" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="flex gap-2">
<Mail className="h-5 w-5" /> Email Preview
</CardTitle>
</CardHeader>
<CardContent>
<EmailPreview
subject={`Invoice ${formData.invoiceNumber} from ${
selectedBusiness?.name ?? "Your Business"
}`}
fromEmail={selectedBusiness?.email ?? ""}
toEmail={selectedClient?.email ?? ""}
content=""
customMessage={emailPreviewMessage}
invoice={{
invoiceNumber: formData.invoiceNumber,
issueDate: formData.issueDate,
dueDate: formData.dueDate,
taxRate: formData.taxRate,
status: formData.status,
totalAmount: totals.total,
currency: formData.currency,
client: selectedClient
? {
name: selectedClient.name,
email: selectedClient.email,
}
: undefined,
business: selectedBusiness
? {
name: selectedBusiness.name,
email: selectedBusiness.email,
}
: undefined,
items: formData.items.map((item) => ({
id: item.id,
date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.hours * item.rate,
})),
}}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs> </Tabs>
</div> </div>
+66 -169
View File
@@ -1,11 +1,6 @@
"use client"; "use client";
import { import { Plus, Trash2 } from "lucide-react";
ChevronDown,
ChevronUp,
Plus,
Trash2,
} from "lucide-react";
import * as React from "react"; import * as React from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -33,9 +28,6 @@ interface InvoiceLineItemsProps {
field: string, field: string,
value: string | number | Date, value: string | number | Date,
) => void; ) => void;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
onReorderItems: (items: InvoiceItem[]) => void;
className?: string; className?: string;
} }
@@ -49,130 +41,67 @@ interface LineItemRowProps {
field: string, field: string,
value: string | number | Date, value: string | number | Date,
) => void; ) => void;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
isFirst: boolean;
isLast: boolean;
} }
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>( const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
( ({ item, index, canRemove, onRemove, onUpdate }, ref) => {
{
item,
index,
canRemove,
onRemove,
onUpdate,
onMoveUp,
onMoveDown,
isFirst,
isLast,
},
ref,
) => {
return ( return (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"bg-card border hidden rounded-xl p-4 md:block transition-all shadow-sm group hover:border-primary/20", "group hover:bg-muted/40 hidden min-h-16 grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] items-center gap-2 border-b px-3 py-2 transition-colors md:grid",
)} )}
> >
<div className="flex items-center gap-3"> <DatePicker
{/* Arrow Controls */} date={item.date}
<div className="flex flex-col gap-0.5"> onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
<Button size="sm"
type="button" className="w-full"
variant="ghost" inputClassName="h-9"
size="sm" />
onClick={() => onMoveUp(index)}
className="h-6 w-6 p-0"
disabled={isFirst}
aria-label="Move up"
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveDown(index)}
className="h-6 w-6 p-0"
disabled={isLast}
aria-label="Move down"
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* Main Content */} <Input
<div className="flex-1 space-y-3"> value={item.description}
{/* Description */} onChange={(e) => onUpdate(index, "description", e.target.value)}
<div> placeholder="Describe the work performed..."
<Input className="h-9 w-full text-sm font-medium"
value={item.description} />
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="w-full text-sm font-medium"
/>
</div>
{/* Controls Row */} <NumberInput
<div className="flex flex-wrap items-center gap-3"> value={item.hours}
{/* Date */} onChange={(value) => onUpdate(index, "hours", value)}
<DatePicker min={0}
date={item.date} step={0.25}
onDateChange={(date) => width="full"
onUpdate(index, "date", date ?? new Date()) className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12"
} suffix="h"
size="sm" />
className="w-full sm:w-[180px]"
inputClassName="h-9"
/>
{/* Hours */} <NumberInput
<NumberInput value={item.rate}
value={item.hours} onChange={(value) => onUpdate(index, "rate", value)}
onChange={(value) => onUpdate(index, "hours", value)} min={0}
min={0} step={1}
step={0.25} prefix="$"
width="auto" width="full"
className="h-9 flex-1 min-w-[100px] font-mono" className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-14"
suffix="h" />
/>
{/* Rate */} <div className="text-primary text-right font-mono font-semibold">
<NumberInput ${(item.hours * item.rate).toFixed(2)}
value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)}
min={0}
step={1}
prefix="$"
width="auto"
className="h-9 flex-1 min-w-[100px] font-mono"
/>
{/* Amount */}
<div className="ml-auto">
<span className="text-primary font-semibold">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
{/* Actions */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div> </div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
); );
}, },
@@ -185,10 +114,6 @@ function MobileLineItem({
canRemove, canRemove,
onRemove, onRemove,
onUpdate, onUpdate,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}: LineItemRowProps) { }: LineItemRowProps) {
return ( return (
<motion.div <motion.div
@@ -253,28 +178,6 @@ function MobileLineItem({
{/* Bottom section with controls, item name, and total */} {/* Bottom section with controls, item name, and total */}
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2"> <div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveUp(index)}
className="h-8 w-8 p-0"
disabled={isFirst}
aria-label="Move up"
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveDown(index)}
className="h-8 w-8 p-0"
disabled={isLast}
aria-label="Move down"
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -310,8 +213,6 @@ export function InvoiceLineItems({
onAddItem, onAddItem,
onRemoveItem, onRemoveItem,
onUpdateItem, onUpdateItem,
onMoveUp,
onMoveDown,
className, className,
}: InvoiceLineItemsProps) { }: InvoiceLineItemsProps) {
const canRemoveItems = items.length > 1; const canRemoveItems = items.length > 1;
@@ -319,7 +220,15 @@ export function InvoiceLineItems({
return ( return (
<div className={cn("space-y-2", className)}> <div className={cn("space-y-2", className)}>
<AnimatePresence> <AnimatePresence>
<div className="space-y-2"> <div className="space-y-2 md:space-y-0 md:overflow-hidden md:rounded-lg md:border">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] gap-2 border-b px-3 py-2 text-xs font-medium md:grid">
<span>Date</span>
<span>Description</span>
<span className="text-right">Hours</span>
<span className="text-right">Rate</span>
<span className="text-right">Amount</span>
<span />
</div>
{items.map((item, index) => ( {items.map((item, index) => (
<React.Fragment key={item.id}> <React.Fragment key={item.id}>
{/* Desktop/Tablet Card */} {/* Desktop/Tablet Card */}
@@ -337,10 +246,6 @@ export function InvoiceLineItems({
canRemove={canRemoveItems} canRemove={canRemoveItems}
onRemove={onRemoveItem} onRemove={onRemoveItem}
onUpdate={onUpdateItem} onUpdate={onUpdateItem}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={index === 0}
isLast={index === items.length - 1}
/> />
</motion.div> </motion.div>
@@ -351,10 +256,6 @@ export function InvoiceLineItems({
canRemove={canRemoveItems} canRemove={canRemoveItems}
onRemove={onRemoveItem} onRemove={onRemoveItem}
onUpdate={onUpdateItem} onUpdate={onUpdateItem}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={index === 0}
isLast={index === items.length - 1}
/> />
</React.Fragment> </React.Fragment>
))} ))}
@@ -362,19 +263,15 @@ export function InvoiceLineItems({
</AnimatePresence> </AnimatePresence>
{/* Add Item Button */} {/* Add Item Button */}
<div className="px-3 pt-3"> <Button
<div className="border-t pt-6"> type="button"
<Button variant="outline"
type="button" onClick={onAddItem}
variant="outline" className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 mt-3 w-full border-dashed py-6 transition-all"
onClick={onAddItem} >
className="w-full border-dashed border-border py-8 text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 transition-all" <Plus className="mr-2 h-4 w-4" />
> Add Line Item
<Plus className="mr-2 h-4 w-4" /> </Button>
Add Line Item
</Button>
</div>
</div>
</div> </div>
); );
} }
@@ -9,98 +9,93 @@ import { InvoiceCalendarView } from "../invoice-calendar-view";
import type { InvoiceFormData } from "./types"; import type { InvoiceFormData } from "./types";
interface InvoiceWorkspaceProps { interface InvoiceWorkspaceProps {
formData: InvoiceFormData; formData: InvoiceFormData;
viewMode: "list" | "calendar"; viewMode: "list" | "calendar";
setViewMode: (mode: "list" | "calendar") => void; setViewMode: (mode: "list" | "calendar") => void;
addItem: (date?: Date) => void; addItem: (date?: Date) => void;
removeItem: (index: number) => void; removeItem: (index: number) => void;
updateItem: (index: number, field: string, value: string | number | Date) => void; updateItem: (
moveItemUp: (index: number) => void; index: number,
moveItemDown: (index: number) => void; field: string,
reorderItems: (items: InvoiceFormData['items']) => void; value: string | number | Date,
className?: string; ) => void;
className?: string;
} }
export function InvoiceWorkspace({ export function InvoiceWorkspace({
formData, formData,
viewMode, viewMode,
setViewMode, setViewMode,
addItem, addItem,
removeItem, removeItem,
updateItem, updateItem,
moveItemUp, className,
moveItemDown,
reorderItems,
className,
}: InvoiceWorkspaceProps) { }: InvoiceWorkspaceProps) {
return (
return ( <div className={cn("flex h-full flex-col", className)}>
<div className={cn("flex flex-col h-full", className)}> {/* Workspace Header / View Toggle */}
{/* Workspace Header / View Toggle */} <div className="bg-background/50 sticky top-0 z-10 flex items-center justify-between border-b p-4 backdrop-blur-sm">
<div className="flex items-center justify-between p-4 border-b bg-background/50 backdrop-blur-sm sticky top-0 z-10"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <h2 className="text-lg font-semibold tracking-tight">
<h2 className="text-lg font-semibold tracking-tight"> {viewMode === "list" ? "Line Items" : "Timesheet"}
{viewMode === 'list' ? 'Line Items' : 'Timesheet'} </h2>
</h2> <div className="text-muted-foreground ml-2 text-sm">
<div className="text-sm text-muted-foreground ml-2"> {formData.items.length}{" "}
{formData.items.length} {formData.items.length === 1 ? 'entry' : 'entries'} {formData.items.length === 1 ? "entry" : "entries"}
</div> </div>
</div>
<div className="flex items-center bg-secondary/50 p-1 rounded-lg">
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="h-8 gap-2 text-xs"
>
<List className="w-3.5 h-3.5" />
List
</Button>
<Button
variant={viewMode === 'calendar' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('calendar')}
className="h-8 gap-2 text-xs"
>
<CalendarIcon className="w-3.5 h-3.5" />
Calendar
</Button>
</div>
</div>
{/* Workspace Content */}
<div className="flex-1 overflow-hidden relative">
<div className="absolute inset-0 overflow-y-auto p-6 md:p-8">
{viewMode === 'list' ? (
<div className="max-w-4xl mx-auto space-y-6">
<div className="bg-background/40 backdrop-blur-md rounded-xl border border-white/10 p-1">
<InvoiceLineItems
items={formData.items}
onAddItem={() => addItem()}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
onMoveUp={moveItemUp}
onMoveDown={moveItemDown}
onReorderItems={reorderItems}
className="p-4"
/>
</div>
</div>
) : (
<div className="h-full">
<InvoiceCalendarView
items={formData.items}
onAddItem={addItem}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
defaultHourlyRate={formData.defaultHourlyRate}
className="h-full"
/>
</div>
)}
</div>
</div>
</div> </div>
);
<div className="bg-secondary/50 flex items-center rounded-lg p-1">
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("list")}
className="h-8 gap-2 text-xs"
>
<List className="h-3.5 w-3.5" />
List
</Button>
<Button
variant={viewMode === "calendar" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("calendar")}
className="h-8 gap-2 text-xs"
>
<CalendarIcon className="h-3.5 w-3.5" />
Calendar
</Button>
</div>
</div>
{/* Workspace Content */}
<div className="relative flex-1 overflow-hidden">
<div className="absolute inset-0 overflow-y-auto p-6 md:p-8">
{viewMode === "list" ? (
<div className="mx-auto max-w-4xl space-y-6">
<div className="bg-background/40 rounded-xl border border-white/10 p-1 backdrop-blur-md">
<InvoiceLineItems
items={formData.items}
onAddItem={() => addItem()}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
className="p-4"
/>
</div>
</div>
) : (
<div className="h-full">
<InvoiceCalendarView
items={formData.items}
onAddItem={addItem}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
defaultHourlyRate={formData.defaultHourlyRate}
className="h-full"
/>
</div>
)}
</div>
</div>
</div>
);
} }
+22 -20
View File
@@ -4,30 +4,32 @@ export type ClientType = RouterOutputs["clients"]["getAll"][number];
export type BusinessType = RouterOutputs["businesses"]["getAll"][number]; export type BusinessType = RouterOutputs["businesses"]["getAll"][number];
export interface InvoiceItem { export interface InvoiceItem {
id: string; id: string;
date: Date; date: Date;
description: string; description: string;
hours: number; hours: number;
rate: number; rate: number;
amount: number; amount: number;
} }
export interface InvoiceFormData { export interface InvoiceFormData {
invoiceNumber: string; invoiceNumber: string;
businessId: string; invoicePrefix: string;
clientId: string; businessId: string;
issueDate: Date; clientId: string;
dueDate: Date; issueDate: Date;
status: "draft" | "sent" | "paid"; dueDate: Date;
notes: string; status: "draft" | "sent" | "paid";
taxRate: number; notes: string;
currency: string; emailMessage: string;
defaultHourlyRate: number | null; taxRate: number;
items: InvoiceItem[]; currency: string;
defaultHourlyRate: number | null;
items: InvoiceItem[];
} }
export const STATUS_OPTIONS = [ export const STATUS_OPTIONS = [
{ value: "draft", label: "Draft" }, { value: "draft", label: "Draft" },
{ value: "sent", label: "Sent" }, { value: "sent", label: "Sent" },
{ value: "paid", label: "Paid" }, { value: "paid", label: "Paid" },
] as const; ] as const;
-451
View File
@@ -1,451 +0,0 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button";
import { EmailComposer } from "./email-composer";
import { EmailPreview } from "./email-preview";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import {
Send,
Loader2,
Eye,
Edit3,
CheckCircle,
AlertTriangle,
Mail,
} from "lucide-react";
import { Alert, AlertDescription } from "~/components/ui/alert";
interface SendEmailDialogProps {
invoiceId: string;
trigger: React.ReactNode;
invoice?: {
id: string;
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
status: string;
taxRate: number;
client?: {
name: string;
email: string | null;
};
business?: {
name: string;
email: string | null;
};
items?: Array<{
id: string;
hours: number;
rate: number;
}>;
};
onEmailSent?: () => void;
}
export function SendEmailDialog({
invoiceId,
trigger,
invoice,
onEmailSent,
}: SendEmailDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState("compose");
const [isSending, setIsSending] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
// Email content state
const [subject, setSubject] = useState(() =>
invoice
? `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`
: "Invoice from Your Business",
);
const [ccEmail, setCcEmail] = useState("");
const [bccEmail, setBccEmail] = useState("");
const [customMessage, setCustomMessage] = useState("");
const [emailContent, setEmailContent] = useState(() => {
const getTimeOfDayGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
};
if (!invoice) return "";
const businessName = invoice.business?.name ?? "Your Business";
const issueDate = formatDate(invoice.issueDate);
// Calculate total from items
const subtotal =
invoice.items?.reduce((sum, item) => sum + item.hours * item.rate, 0) ??
0;
const taxAmount = subtotal * (invoice.taxRate / 100);
const total = subtotal + taxAmount;
return `<p>${getTimeOfDayGreeting()},</p>
<p>I hope this email finds you well. Please find attached invoice <strong>${invoice.invoiceNumber}</strong> dated ${issueDate}.</p>
<p>The invoice details are as follows:</p>
<ul>
<li><strong>Invoice Number:</strong> ${invoice.invoiceNumber}</li>
<li><strong>Issue Date:</strong> ${issueDate}</li>
<li><strong>Amount Due:</strong> ${new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)}</li>
</ul>
<p>Please let me know if you have any questions or need any clarification regarding this invoice. I appreciate your prompt attention to this matter.</p>
<p>Thank you for your business!</p>
<p>Best regards,<br><strong>${businessName}</strong></p>`;
});
// 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,
});
// Reset state and close dialog
setIsOpen(false);
setActiveTab("compose");
setIsSending(false);
setIsConfirming(false);
// Refresh invoice data
void utils.invoices.getById.invalidate({ id: invoiceId });
// Callback for parent component
onEmailSent?.();
},
onError: (error) => {
console.error("Email send error:", error);
let errorMessage = "Failed to send invoice email";
let errorDescription = error.message;
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.";
}
toast.error(errorMessage, {
description: errorDescription,
duration: 6000,
});
setIsSending(false);
setIsConfirming(false);
},
});
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;
}
if (!emailContent.trim()) {
toast.error("Message required", {
description: "Please enter an email message before sending.",
});
return;
}
setIsSending(true);
try {
// Use the enhanced API with custom subject and content
await sendEmailMutation.mutateAsync({
invoiceId,
customSubject: subject,
customContent: emailContent,
customMessage: customMessage.trim() || undefined,
useHtml: true,
ccEmails: ccEmail.trim() || undefined,
bccEmails: bccEmail.trim() || undefined,
});
} catch (error) {
// Error handling is done in the mutation's onError
console.error("Send email error:", error);
}
};
const handleConfirmSend = () => {
setIsConfirming(true);
setActiveTab("confirm");
};
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com";
const toEmail = invoice?.client?.email ?? "";
const canSend =
!isSending &&
subject.trim() &&
emailContent.trim() &&
toEmail &&
toEmail.trim() !== "";
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="text-primary h-5 w-5" />
Send Invoice Email
</DialogTitle>
<DialogDescription>
Compose and preview your invoice email before sending to{" "}
{invoice?.client?.name ?? "client"}.
</DialogDescription>
</DialogHeader>
{/* Warning for missing email */}
{(!toEmail || toEmail.trim() === "") && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This client doesn&apos;t have an email address. Please add an
email address to the client before sending the invoice.
</AlertDescription>
</Alert>
)}
{/* Branded Template Info */}
<Alert>
<Mail className="h-4 w-4" />
<AlertDescription>
<strong>Professional Email Template:</strong> Your email will be
sent using a beautifully designed, beenvoice-branded template with
proper fonts and styling. Any custom content you add will be
incorporated into the professional template automatically.
</AlertDescription>
</Alert>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="min-h-0 flex-1"
>
<TabsList className="grid w-full grid-cols-3">
<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>
<TabsTrigger
value="confirm"
className="flex items-center gap-2"
disabled={!isConfirming}
>
<CheckCircle className="h-4 w-4" />
Confirm
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent
value="compose"
className="mt-4 h-full overflow-y-auto"
>
<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}
/>
</TabsContent>
<TabsContent
value="preview"
className="mt-4 h-full overflow-y-auto"
>
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
bccEmail={bccEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
className="pr-2"
/>
</TabsContent>
<TabsContent
value="confirm"
className="mt-4 h-full overflow-y-auto"
>
<div className="space-y-6 pr-2">
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
You&apos;re about to send this email to{" "}
<strong>{toEmail}</strong>. The invoice PDF will be
automatically attached.
</AlertDescription>
</Alert>
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
/>
{invoice?.status === "draft" && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This invoice is currently in <strong>draft</strong>{" "}
status. Sending it will automatically change the status to{" "}
<strong>sent</strong>.
</AlertDescription>
</Alert>
)}
</div>
</TabsContent>
</div>
</Tabs>
<DialogFooter className="flex items-center justify-between">
<div className="flex items-center gap-2">
{activeTab === "compose" && (
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
disabled={isSending}
>
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
)}
{activeTab === "preview" && (
<>
<Button
variant="outline"
onClick={() => setActiveTab("compose")}
disabled={isSending}
>
<Edit3 className="mr-2 h-4 w-4" />
Edit
</Button>
<Button
onClick={handleConfirmSend}
disabled={!canSend}
variant="default"
>
<CheckCircle className="mr-2 h-4 w-4" />
Review & Send
</Button>
</>
)}
{activeTab === "confirm" && (
<>
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
disabled={isSending}
>
Back to Preview
</Button>
<Button
onClick={handleSendEmail}
disabled={!canSend || isSending}
className="bg-primary hover:bg-primary/90"
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Send Email
</>
)}
</Button>
</>
)}
</div>
<Button
variant="ghost"
onClick={() => setIsOpen(false)}
disabled={isSending}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+10 -7
View File
@@ -8,9 +8,11 @@ import { Menu } from "lucide-react";
import { Logo } from "~/components/branding/logo"; import { Logo } from "~/components/branding/logo";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { useAppearance } from "~/components/providers/appearance-provider";
function DashboardContent({ children }: { children: React.ReactNode }) { function DashboardContent({ children }: { children: React.ReactNode }) {
const { isCollapsed } = useSidebar(); const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance();
const [isMobileOpen, setIsMobileOpen] = React.useState(false); const [isMobileOpen, setIsMobileOpen] = React.useState(false);
return ( return (
@@ -21,7 +23,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
</div> </div>
{/* Mobile Sidebar (Sheet) */} {/* Mobile Sidebar (Sheet) */}
<div className="md:hidden fixed top-0 left-0 right-0 h-16 bg-background/80 backdrop-blur-md border-b z-50 px-4 flex items-center"> <div className="fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b bg-background/80 px-4 backdrop-blur-md md:hidden">
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}> <Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="outline" size="icon" className="h-10 w-10 bg-background shadow-sm" suppressHydrationWarning> <Button variant="outline" size="icon" className="h-10 w-10 bg-background shadow-sm" suppressHydrationWarning>
@@ -47,13 +49,14 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
suppressHydrationWarning suppressHydrationWarning
className={cn( className={cn(
"flex-1 min-h-screen min-w-0 transition-all duration-300 ease-in-out", "flex-1 min-h-screen min-w-0 transition-all duration-300 ease-in-out",
// Desktop margins based on collapsed state
"md:ml-0", "md:ml-0",
// Sidebar is fixed at left: 1rem (16px), width: 16rem (256px) or 4rem (64px) sidebarStyle === "floating"
// We need margin-left = left + width + gap ? isCollapsed
// Expanded: 16px + 256px + 16px (gap) = 288px (18rem) ? "md:ml-24"
// Collapsed: 16px + 64px + 16px (gap) = 96px (6rem) : "md:ml-[18rem]"
isCollapsed ? "md:ml-24" : "md:ml-[18rem]" : isCollapsed
? "md:ml-16"
: "md:ml-64",
)} )}
> >
<div className="p-4 pt-16 md:pt-4"> <div className="p-4 pt-16 md:pt-4">
+13 -47
View File
@@ -1,8 +1,10 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import { useAppearance } from "~/components/providers/appearance-provider";
import { useSidebar } from "~/components/layout/sidebar-provider";
interface FloatingActionBarProps { interface FloatingActionBarProps {
/** Content to display on the left side */ /** Content to display on the left side */
@@ -13,74 +15,38 @@ interface FloatingActionBarProps {
className?: string; className?: string;
} }
import { useSidebar } from "~/components/layout/sidebar-provider";
export function FloatingActionBar({ export function FloatingActionBar({
leftContent, leftContent,
children, children,
className, className,
}: FloatingActionBarProps) { }: FloatingActionBarProps) {
const [isDocked, setIsDocked] = useState(false);
const { isCollapsed } = useSidebar(); const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance();
useEffect(() => {
const handleScroll = () => {
// Check if we're truly at the bottom of the page
const scrollHeight = document.documentElement.scrollHeight;
const scrollTop = document.documentElement.scrollTop;
const clientHeight = document.documentElement.clientHeight;
const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
// Only dock when we're within 50px of the actual bottom AND there's content to scroll
const hasScrollableContent = scrollHeight > clientHeight;
const shouldDock = hasScrollableContent && distanceFromBottom <= 50;
// If content is too small, keep it at bottom of viewport
const contentTooSmall = scrollHeight <= clientHeight + 200;
setIsDocked(shouldDock && !contentTooSmall);
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll(); // Check initial state
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return ( return (
<div <div
className={cn( className={cn(
// Base positioning - always at bottom "pb-safe-area-inset-bottom fixed right-0 bottom-4 left-0 z-50 transition-all duration-300 ease-in-out",
"fixed right-0 z-50 transition-all duration-300 ease-in-out", sidebarStyle === "floating"
// Safe area and sidebar adjustments ? isCollapsed
"pb-safe-area-inset-bottom left-0", ? "md:left-24"
isCollapsed ? "md:left-24" : "md:left-[18rem]", : "md:left-[18rem]"
// Conditional centering based on dock state : isCollapsed
isDocked ? "flex justify-center" : "", ? "md:left-16"
// Dynamic bottom positioning : "md:left-64",
isDocked ? "bottom-4" : "bottom-0",
// Add entrance animation
"animate-slide-in-bottom", "animate-slide-in-bottom",
className, className,
)} )}
> >
{/* Content container - full width when floating, content width when docked */} <div className="w-full px-4 transition-transform duration-300">
<div
className={cn(
"w-full transition-transform duration-300",
isDocked ? "mx-auto mb-0 px-4" : "mb-4 px-4",
)}
>
<Card className="hover-lift bg-card border-border border shadow-lg"> <Card className="hover-lift bg-card border-border border shadow-lg">
<CardContent className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm:p-4"> <CardContent className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm:p-4">
{/* Left content */}
{leftContent && ( {leftContent && (
<div className="text-card-foreground animate-fade-in flex flex-1 items-center gap-3"> <div className="text-card-foreground animate-fade-in flex flex-1 items-center gap-3">
{leftContent} {leftContent}
</div> </div>
)} )}
{/* Right actions */}
<div className="animate-fade-in animate-delay-100 flex items-center gap-2 sm:gap-3"> <div className="animate-fade-in animate-delay-100 flex items-center gap-2 sm:gap-3">
{children} {children}
</div> </div>
+16 -10
View File
@@ -8,7 +8,11 @@ import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export function Navbar() { interface NavbarProps {
allowRegistration?: boolean;
}
export function Navbar({ allowRegistration = true }: NavbarProps) {
const { data: session, isPending } = authClient.useSession(); const { data: session, isPending } = authClient.useSession();
// const session = { user: null } as any; const isPending = false; // const session = { user: null } as any; const isPending = false;
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false); const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
@@ -63,15 +67,17 @@ export function Navbar() {
Sign In Sign In
</Button> </Button>
</Link> </Link>
<Link href="/auth/register"> {allowRegistration && (
<Button <Link href="/auth/register">
size="sm" <Button
variant="default" size="sm"
className="text-xs font-medium md:text-sm" variant="default"
> className="text-xs font-medium md:text-sm"
Register >
</Button> Register
</Link> </Button>
</Link>
)}
</> </>
)} )}
</div> </div>
+3 -3
View File
@@ -42,9 +42,9 @@ export function PageHeader({
return ( return (
<div className={`animate-fade-in-down mb-6 ${className}`}> <div className={`animate-fade-in-down mb-6 ${className}`}>
{variant === "large-gradient" || variant === "gradient" ? ( {variant === "large-gradient" || variant === "gradient" ? (
<div className="rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden relative"> <div className="platform-header-surface rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden relative">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent pointer-events-none" /> <div className="platform-header-gradient absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent pointer-events-none" />
<div className="p-6 relative"> <div className="platform-header-content p-6 relative">
<DashboardBreadcrumbs className="mb-4" /> <DashboardBreadcrumbs className="mb-4" />
{/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */} {/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
+7 -3
View File
@@ -25,6 +25,7 @@ import {
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { getGravatarUrl } from "~/lib/gravatar"; import { getGravatarUrl } from "~/lib/gravatar";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { useAppearance } from "~/components/providers/appearance-provider";
interface SidebarProps { interface SidebarProps {
mobile?: boolean; mobile?: boolean;
@@ -36,6 +37,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
const { data: session, isPending } = authClient.useSession(); const { data: session, isPending } = authClient.useSession();
// const session = { user: null } as any; const isPending = false; // const session = { user: null } as any; const isPending = false;
const { isCollapsed, toggleCollapse } = useSidebar(); const { isCollapsed, toggleCollapse } = useSidebar();
const { sidebarStyle } = useAppearance();
// If mobile, always expanded // If mobile, always expanded
const collapsed = mobile ? false : isCollapsed; const collapsed = mobile ? false : isCollapsed;
@@ -214,9 +216,11 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
return ( return (
<aside <aside
className={cn( className={cn(
"fixed top-4 bottom-4 left-4 z-30 hidden md:flex flex-col", "fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex",
"bg-background/80 backdrop-blur-xl border-border/50 border shadow-xl rounded-3xl transition-all duration-300 ease-in-out", sidebarStyle === "floating"
isCollapsed ? "w-16" : "w-64" ? "top-4 bottom-4 left-4 border-border/50 rounded-3xl border bg-background/80 shadow-xl backdrop-blur-xl"
: "top-0 bottom-0 left-0 rounded-none border-r border-border bg-background shadow-none",
isCollapsed ? "w-16" : "w-64",
)} )}
> >
{SidebarContent} {SidebarContent}
@@ -0,0 +1,384 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
defaultFontPreference,
defaultBodyFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
brand as defaultBrand,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/branding";
import { api } from "~/trpc/react";
type AppearancePreferences = {
interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
colorMode: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: "classic" | "minimal";
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
type AppearancePatch = Partial<AppearancePreferences>;
type ServerAppearance = {
interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
theme: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: "classic" | "minimal";
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
type AppearanceContextValue = AppearancePreferences & {
updateAppearance: (patch: AppearancePatch) => void;
isUpdating: boolean;
};
const STORAGE_KEY = "bv.appearance";
const defaultAppearance: AppearancePreferences = {
interfaceTheme: defaultInterfaceTheme,
fontPreference: defaultFontPreference,
bodyFontPreference: defaultBodyFontPreference,
headingFontPreference: defaultHeadingFontPreference,
radiusPreference: defaultRadiusPreference,
sidebarStyle: defaultSidebarStyle,
colorMode: "system",
colorTheme: "slate",
brandName: defaultBrand.name,
brandTagline: defaultBrand.tagline,
brandLogoText: defaultBrand.logoText,
brandIcon: defaultBrand.icon,
pdfTemplate: "classic",
pdfAccentColor: "#111827",
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
};
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
function getServerAppearancePatch(
serverAppearance: ServerAppearance,
): AppearancePatch {
return {
interfaceTheme: serverAppearance.interfaceTheme,
fontPreference: serverAppearance.fontPreference,
bodyFontPreference: serverAppearance.bodyFontPreference,
headingFontPreference: serverAppearance.headingFontPreference,
radiusPreference: serverAppearance.radiusPreference,
sidebarStyle: serverAppearance.sidebarStyle,
colorMode: serverAppearance.theme,
colorTheme: serverAppearance.colorTheme,
customColor: serverAppearance.customColor,
brandName: serverAppearance.brandName,
brandTagline: serverAppearance.brandTagline,
brandLogoText: serverAppearance.brandLogoText,
brandIcon: serverAppearance.brandIcon,
pdfTemplate: serverAppearance.pdfTemplate,
pdfAccentColor: serverAppearance.pdfAccentColor,
pdfFooterText: serverAppearance.pdfFooterText,
pdfShowLogo: serverAppearance.pdfShowLogo,
pdfShowPageNumbers: serverAppearance.pdfShowPageNumbers,
};
}
function isInterfaceTheme(value: unknown): value is InterfaceTheme {
return (
value === "beenvoice" ||
value === "shadcn" ||
value === "minimal" ||
value === "editorial"
);
}
function isFontPreference(value: unknown): value is FontPreference {
return (
value === "brand" ||
value === "platform" ||
value === "inter" ||
value === "serif"
);
}
function isColorMode(value: unknown): value is ColorMode {
return value === "light" || value === "dark" || value === "system";
}
function isColorTheme(value: unknown): value is ColorTheme {
return (
value === "slate" ||
value === "blue" ||
value === "green" ||
value === "rose" ||
value === "orange" ||
value === "custom"
);
}
function isRadiusPreference(value: unknown): value is RadiusPreference {
return (
value === "none" ||
value === "sm" ||
value === "md" ||
value === "lg" ||
value === "xl"
);
}
function isSidebarStyle(value: unknown): value is SidebarStyle {
return value === "floating" || value === "docked";
}
function readStoredAppearance(): Partial<AppearancePreferences> | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
interfaceTheme: isInterfaceTheme(parsed.interfaceTheme)
? parsed.interfaceTheme
: undefined,
fontPreference: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
bodyFontPreference: isFontPreference(parsed.bodyFontPreference)
? parsed.bodyFontPreference
: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
headingFontPreference: isFontPreference(parsed.headingFontPreference)
? parsed.headingFontPreference
: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
radiusPreference: isRadiusPreference(parsed.radiusPreference)
? parsed.radiusPreference
: undefined,
sidebarStyle: isSidebarStyle(parsed.sidebarStyle)
? parsed.sidebarStyle
: undefined,
colorMode: isColorMode(parsed.colorMode) ? parsed.colorMode : undefined,
colorTheme: isColorTheme(parsed.colorTheme)
? parsed.colorTheme
: undefined,
customColor:
typeof parsed.customColor === "string" ? parsed.customColor : undefined,
brandName:
typeof parsed.brandName === "string" ? parsed.brandName : undefined,
brandTagline:
typeof parsed.brandTagline === "string"
? parsed.brandTagline
: undefined,
brandLogoText:
typeof parsed.brandLogoText === "string"
? parsed.brandLogoText
: undefined,
brandIcon:
typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined,
pdfTemplate:
parsed.pdfTemplate === "classic" || parsed.pdfTemplate === "minimal"
? parsed.pdfTemplate
: undefined,
pdfAccentColor:
typeof parsed.pdfAccentColor === "string"
? parsed.pdfAccentColor
: undefined,
pdfFooterText:
typeof parsed.pdfFooterText === "string"
? parsed.pdfFooterText
: undefined,
pdfShowLogo:
typeof parsed.pdfShowLogo === "boolean"
? parsed.pdfShowLogo
: undefined,
pdfShowPageNumbers:
typeof parsed.pdfShowPageNumbers === "boolean"
? parsed.pdfShowPageNumbers
: undefined,
};
} catch {
return null;
}
}
function writeStoredAppearance(prefs: AppearancePreferences) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// Storage can be unavailable in private browsing or locked-down contexts.
}
}
function applyAppearance(prefs: AppearancePreferences) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.dataset.interfaceTheme = prefs.interfaceTheme;
root.dataset.font = prefs.fontPreference;
root.dataset.bodyFont = prefs.bodyFontPreference;
root.dataset.headingFont = prefs.headingFontPreference;
root.dataset.radius = prefs.radiusPreference;
root.dataset.sidebarStyle = prefs.sidebarStyle;
root.dataset.colorMode = prefs.colorMode;
root.dataset.colorTheme = prefs.colorTheme;
root.classList.toggle("dark", prefs.colorMode === "dark");
if (prefs.customColor) {
root.style.setProperty("--custom-primary", prefs.customColor);
} else {
root.style.removeProperty("--custom-primary");
}
}
export function AppearanceProvider({
children,
}: {
children: React.ReactNode;
}) {
const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance);
const utils = api.useUtils();
const updateMutation = api.settings.updateTheme.useMutation({
onSuccess: async () => {
await utils.settings.getTheme.invalidate();
},
onError: () => {
const cachedAppearance = utils.settings.getTheme.getData();
const fallback = cachedAppearance
? {
...defaultAppearance,
...getServerAppearancePatch(cachedAppearance),
}
: defaultAppearance;
setAppearance(fallback);
applyAppearance(fallback);
writeStoredAppearance(fallback);
},
});
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
useEffect(() => {
const storedAppearance = readStoredAppearance();
if (!storedAppearance) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
}, []);
useEffect(() => {
if (!serverAppearance) return;
const next = getServerAppearancePatch(serverAppearance);
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...next }));
}, [serverAppearance]);
useEffect(() => {
applyAppearance(appearance);
writeStoredAppearance(appearance);
}, [appearance]);
const updateAppearance = useCallback(
(patch: AppearancePatch) => {
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
updateMutation.mutate({
interfaceTheme: patch.interfaceTheme,
fontPreference: patch.fontPreference,
bodyFontPreference: patch.bodyFontPreference,
headingFontPreference: patch.headingFontPreference,
radiusPreference: patch.radiusPreference,
sidebarStyle: patch.sidebarStyle,
theme: patch.colorMode,
colorTheme: patch.colorTheme,
customColor: patch.customColor,
brandName: patch.brandName,
brandTagline: patch.brandTagline,
brandLogoText: patch.brandLogoText,
brandIcon: patch.brandIcon,
pdfTemplate: patch.pdfTemplate,
pdfAccentColor: patch.pdfAccentColor,
pdfFooterText: patch.pdfFooterText,
pdfShowLogo: patch.pdfShowLogo,
pdfShowPageNumbers: patch.pdfShowPageNumbers,
});
},
[updateMutation],
);
const value = useMemo<AppearanceContextValue>(
() => ({
...appearance,
updateAppearance,
isUpdating: updateMutation.isPending,
}),
[appearance, updateAppearance, updateMutation.isPending],
);
return (
<AppearanceContext.Provider value={value}>
{children}
</AppearanceContext.Provider>
);
}
export function useAppearance() {
const ctx = useContext(AppearanceContext);
if (!ctx) {
throw new Error("useAppearance must be used within an AppearanceProvider");
}
return ctx;
}
+42 -4
View File
@@ -13,19 +13,18 @@ export const env = createEnv({
: z.string().optional(), : z.string().optional(),
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
BETTER_AUTH_URL: z.string().url().optional(), BETTER_AUTH_URL: z.string().url().optional(),
RESEND_API_KEY: RESEND_API_KEY: z.string().min(1).optional(),
process.env.NODE_ENV === "production"
? z.string().min(1)
: z.string().min(1).optional(),
RESEND_DOMAIN: z.string().optional(), RESEND_DOMAIN: z.string().optional(),
NODE_ENV: z NODE_ENV: z
.enum(["development", "test", "production"]) .enum(["development", "test", "production"])
.default("development"), .default("development"),
DB_DISABLE_SSL: z.coerce.boolean().optional(), DB_DISABLE_SSL: z.coerce.boolean().optional(),
DISABLE_SIGNUPS: z.coerce.boolean().optional(),
// SSO / Authentik (optional) // SSO / Authentik (optional)
AUTHENTIK_ISSUER: z.string().url().optional(), AUTHENTIK_ISSUER: z.string().url().optional(),
AUTHENTIK_CLIENT_ID: z.string().optional(), AUTHENTIK_CLIENT_ID: z.string().optional(),
AUTHENTIK_CLIENT_SECRET: z.string().optional(), AUTHENTIK_CLIENT_SECRET: z.string().optional(),
AUTHENTIK_ORIGIN: z.string().url().optional(),
}, },
/** /**
@@ -37,6 +36,29 @@ export const env = createEnv({
NEXT_PUBLIC_APP_URL: z.string().url().optional(), NEXT_PUBLIC_APP_URL: z.string().url().optional(),
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(), NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().optional(), NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().optional(),
NEXT_PUBLIC_AUTHENTIK_ENABLED: z.coerce.boolean().optional(),
NEXT_PUBLIC_BRAND_NAME: z.string().optional(),
NEXT_PUBLIC_BRAND_TAGLINE: z.string().optional(),
NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(),
NEXT_PUBLIC_BRAND_ICON: z.string().optional(),
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME: z
.enum(["beenvoice", "shadcn", "minimal", "editorial"])
.optional(),
NEXT_PUBLIC_DEFAULT_FONT: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_BODY_FONT: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_HEADING_FONT: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_RADIUS: z
.enum(["none", "sm", "md", "lg", "xl"])
.optional(),
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE: z
.enum(["floating", "docked"])
.optional(),
}, },
/** /**
@@ -51,12 +73,28 @@ export const env = createEnv({
RESEND_DOMAIN: process.env.RESEND_DOMAIN, RESEND_DOMAIN: process.env.RESEND_DOMAIN,
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
DB_DISABLE_SSL: process.env.DB_DISABLE_SSL, DB_DISABLE_SSL: process.env.DB_DISABLE_SSL,
DISABLE_SIGNUPS: process.env.DISABLE_SIGNUPS,
AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER, AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER,
AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID,
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET, AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
AUTHENTIK_ORIGIN: process.env.AUTHENTIK_ORIGIN,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL, NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
NEXT_PUBLIC_AUTHENTIK_ENABLED: process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED,
NEXT_PUBLIC_BRAND_NAME: process.env.NEXT_PUBLIC_BRAND_NAME,
NEXT_PUBLIC_BRAND_TAGLINE: process.env.NEXT_PUBLIC_BRAND_TAGLINE,
NEXT_PUBLIC_BRAND_LOGO_TEXT: process.env.NEXT_PUBLIC_BRAND_LOGO_TEXT,
NEXT_PUBLIC_BRAND_ICON: process.env.NEXT_PUBLIC_BRAND_ICON,
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME:
process.env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME,
NEXT_PUBLIC_DEFAULT_FONT: process.env.NEXT_PUBLIC_DEFAULT_FONT,
NEXT_PUBLIC_DEFAULT_BODY_FONT: process.env.NEXT_PUBLIC_DEFAULT_BODY_FONT,
NEXT_PUBLIC_DEFAULT_HEADING_FONT:
process.env.NEXT_PUBLIC_DEFAULT_HEADING_FONT,
NEXT_PUBLIC_DEFAULT_RADIUS: process.env.NEXT_PUBLIC_DEFAULT_RADIUS,
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE:
process.env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE,
}, },
/** /**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
+55 -45
View File
@@ -5,55 +5,65 @@ import { genericOAuth } from "better-auth/plugins";
import { db } from "~/server/db"; import { db } from "~/server/db";
import * as schema from "~/server/db/schema"; import * as schema from "~/server/db/schema";
const authentikEnabled = Boolean(
process.env.AUTHENTIK_ISSUER &&
process.env.AUTHENTIK_CLIENT_ID &&
process.env.AUTHENTIK_CLIENT_SECRET,
);
const signupsDisabled = process.env.DISABLE_SIGNUPS === "true";
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "pg", provider: "pg",
schema: { schema: {
user: schema.users, user: schema.users,
session: schema.sessions, session: schema.sessions,
account: schema.accounts, account: schema.accounts,
verification: schema.verificationTokens, verification: schema.verificationTokens,
ssoProvider: schema.ssoProviders, ssoProvider: schema.ssoProviders,
}, },
}), }),
trustedOrigins: [ trustedOrigins: [
"https://beenvoice.soconnor.dev", "https://beenvoice.soconnor.dev",
"https://auth.soconnor.dev", // Authentik IdP for OIDC discovery ...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
], ],
...(authentikEnabled && {
accountLinking: { accountLinking: {
enabled: true, enabled: true,
trustedProviders: ["authentik"], trustedProviders: ["authentik"],
}, },
emailAndPassword: { }),
enabled: true, emailAndPassword: {
password: { enabled: true,
hash: async (password) => { disableSignUp: signupsDisabled,
const bcrypt = await import("bcryptjs"); password: {
return bcrypt.hash(password, 12); hash: async (password) => {
}, const bcrypt = await import("bcryptjs");
verify: async ({ hash, password }) => { return bcrypt.hash(password, 12);
const bcrypt = await import("bcryptjs"); },
return bcrypt.compare(password, hash); verify: async ({ hash, password }) => {
}, const bcrypt = await import("bcryptjs");
}, return bcrypt.compare(password, hash);
},
}, },
plugins: [ },
nextCookies(), plugins: [
genericOAuth({ nextCookies(),
...(authentikEnabled
? [
genericOAuth({
config: [ config: [
{ {
providerId: "authentik", providerId: "authentik",
clientId: process.env.AUTHENTIK_CLIENT_ID!, clientId: process.env.AUTHENTIK_CLIENT_ID!,
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!, clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
discoveryUrl: `${process.env.AUTHENTIK_ISSUER}/.well-known/openid-configuration`, discoveryUrl: `${process.env.AUTHENTIK_ISSUER}/.well-known/openid-configuration`,
// Explicit endpoints to ensure correct routing in production scopes: ["openid", "email", "profile"],
authorizationUrl: "https://auth.soconnor.dev/application/o/authorize/", pkce: true,
tokenUrl: "https://auth.soconnor.dev/application/o/token/", },
userInfoUrl: "https://auth.soconnor.dev/application/o/userinfo/",
scopes: ["openid", "email", "profile"],
pkce: true,
},
], ],
}), }),
], ]
: []),
],
}); });
+249
View File
@@ -0,0 +1,249 @@
import { env } from "~/env";
export type InterfaceTheme = "beenvoice" | "shadcn" | "minimal" | "editorial";
export type FontPreference = "brand" | "platform" | "inter" | "serif";
export type RadiusPreference = "none" | "sm" | "md" | "lg" | "xl";
export type SidebarStyle = "floating" | "docked";
export type ColorMode = "light" | "dark" | "system";
export type ColorTheme =
| "slate"
| "blue"
| "green"
| "rose"
| "orange"
| "custom";
export const interfaceThemes: {
value: InterfaceTheme;
label: string;
description: string;
}[] = [
{
value: "beenvoice",
label: "beenvoice",
description: "Opinionated brand system with expressive headings.",
},
{
value: "shadcn",
label: "shadcn/ui",
description: "A plain shadcn baseline for white-label starts.",
},
{
value: "minimal",
label: "Minimal",
description: "Quiet surfaces, lower contrast, and restrained chrome.",
},
{
value: "editorial",
label: "Editorial",
description: "A warmer presentation style for service-led brands.",
},
];
export const themePresets: Record<
InterfaceTheme,
{
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
colorTheme: ColorTheme;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
}
> = {
beenvoice: {
interfaceTheme: "beenvoice",
bodyFontPreference: "brand",
headingFontPreference: "brand",
colorTheme: "slate",
radiusPreference: "xl",
sidebarStyle: "floating",
},
shadcn: {
interfaceTheme: "shadcn",
bodyFontPreference: "inter",
headingFontPreference: "inter",
colorTheme: "slate",
radiusPreference: "md",
sidebarStyle: "docked",
},
minimal: {
interfaceTheme: "minimal",
bodyFontPreference: "platform",
headingFontPreference: "platform",
colorTheme: "slate",
radiusPreference: "sm",
sidebarStyle: "docked",
},
editorial: {
interfaceTheme: "editorial",
bodyFontPreference: "platform",
headingFontPreference: "serif",
colorTheme: "rose",
radiusPreference: "lg",
sidebarStyle: "floating",
},
};
export const fontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Brand",
description: "Inter body with Playfair headings.",
},
{
value: "platform",
label: "Platform",
description: "Native system fonts for the current OS.",
},
{
value: "inter",
label: "Inter",
description: "Inter for both body and headings.",
},
{
value: "serif",
label: "Editorial",
description: "Serif headings with system body text.",
},
];
export const bodyFontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Brand Sans",
description: "Inter body text for a clean product feel.",
},
{
value: "platform",
label: "Platform",
description: "Native system body text for the current OS.",
},
{
value: "inter",
label: "Inter",
description: "Inter body text, explicitly selected.",
},
{
value: "serif",
label: "Serif",
description: "Georgia-style body text for editorial deployments.",
},
];
export const headingFontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Brand Serif",
description: "Playfair headings for the BeenVoice identity.",
},
{
value: "platform",
label: "Platform",
description: "Native system headings for a neutral app feel.",
},
{
value: "inter",
label: "Inter",
description: "Inter headings for a plain shadcn-style baseline.",
},
{
value: "serif",
label: "Editorial",
description: "Playfair headings with a stronger editorial tone.",
},
];
export const radiusPreferences: {
value: RadiusPreference;
label: string;
description: string;
}[] = [
{ value: "none", label: "Square", description: "No rounded corners." },
{ value: "sm", label: "Small", description: "Subtle 4px rounding." },
{ value: "md", label: "Medium", description: "Standard 8px rounding." },
{ value: "lg", label: "Large", description: "Soft 12px rounding." },
{
value: "xl",
label: "Extra Large",
description: "Expressive 16px rounding.",
},
];
export const sidebarStyles: {
value: SidebarStyle;
label: string;
description: string;
}[] = [
{
value: "floating",
label: "Floating",
description: "Inset navigation with rounded edges and elevation.",
},
{
value: "docked",
label: "Flush",
description: "Full-height navigation aligned to the viewport edge.",
},
];
export const colorThemes: {
value: ColorTheme;
label: string;
swatch: string;
}[] = [
{ value: "slate", label: "Slate", swatch: "hsl(240 5.9% 10%)" },
{ value: "blue", label: "Blue", swatch: "hsl(221.2 83.2% 53.3%)" },
{ value: "green", label: "Green", swatch: "hsl(142.1 76.2% 36.3%)" },
{ value: "rose", label: "Rose", swatch: "hsl(346.8 77.2% 49.8%)" },
{ value: "orange", label: "Orange", swatch: "hsl(24.6 95% 53.1%)" },
];
export const colorModes: {
value: ColorMode;
label: string;
description: string;
}[] = [
{ value: "system", label: "System", description: "Follow device setting." },
{ value: "light", label: "Light", description: "Always use light mode." },
{ value: "dark", label: "Dark", description: "Always use dark mode." },
];
export const defaultInterfaceTheme: InterfaceTheme =
env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? "beenvoice";
export const defaultFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_FONT ?? "brand";
export const defaultBodyFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_BODY_FONT ?? defaultFontPreference;
export const defaultHeadingFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_HEADING_FONT ?? defaultFontPreference;
export const defaultRadiusPreference: RadiusPreference =
env.NEXT_PUBLIC_DEFAULT_RADIUS ?? "xl";
export const defaultSidebarStyle: SidebarStyle =
env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? "floating";
export const brand = {
name: env.NEXT_PUBLIC_BRAND_NAME ?? "beenvoice",
tagline:
env.NEXT_PUBLIC_BRAND_TAGLINE ??
"Simple and efficient invoicing for freelancers and small businesses",
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
};
+2 -6
View File
@@ -6,7 +6,7 @@ interface InvoiceEmailTemplateProps {
status: string; status: string;
totalAmount: number; totalAmount: number;
taxRate: number; taxRate: number;
notes?: string | null; currency?: string | null;
client: { client: {
name: string; name: string;
email: string | null; email: string | null;
@@ -57,7 +57,7 @@ export function generateInvoiceEmailTemplate({
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: invoice.currency ?? "USD",
}).format(amount); }).format(amount);
}; };
@@ -459,8 +459,6 @@ export function generateInvoiceEmailTemplate({
</div> </div>
</div> </div>
<div class="attachment-notice"> <div class="attachment-notice">
<div class="attachment-icon"></div> <div class="attachment-icon"></div>
<div class="attachment-text"> <div class="attachment-text">
@@ -540,8 +538,6 @@ Subtotal: ${formatCurrency(subtotal)}${
} }
Total: ${formatCurrency(total)} Total: ${formatCurrency(total)}
ATTACHMENT ATTACHMENT
═══════════════ ═══════════════
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
+11
View File
@@ -0,0 +1,11 @@
export const EXPENSE_CATEGORIES = [
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
+240 -322
View File
@@ -56,11 +56,13 @@ function downloadBlob(blob: Blob, filename: string): void {
interface InvoiceData { interface InvoiceData {
invoiceNumber: string; invoiceNumber: string;
invoicePrefix?: string | null;
issueDate: Date; issueDate: Date;
dueDate: Date; dueDate: Date;
status: string; status: string;
totalAmount: number; totalAmount: number;
taxRate: number; taxRate: number;
currency?: string | null;
notes?: string | null; notes?: string | null;
business?: { business?: {
name: string; name: string;
@@ -96,6 +98,26 @@ interface InvoiceData {
} | null> | null; } | null> | null;
} }
export interface PDFGenerationSettings {
pdfTemplate?: "classic" | "minimal";
pdfAccentColor?: string;
pdfFooterText?: string;
pdfShowLogo?: boolean;
pdfShowPageNumbers?: boolean;
}
const defaultPDFSettings: Required<PDFGenerationSettings> = {
pdfTemplate: "classic",
pdfAccentColor: "#111827",
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
};
function resolvePDFSettings(settings?: PDFGenerationSettings) {
return { ...defaultPDFSettings, ...settings };
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { page: {
flexDirection: "column", flexDirection: "column",
@@ -321,7 +343,6 @@ const styles = StyleSheet.create({
// Table styles // Table styles
tableContainer: { tableContainer: {
flex: 1,
marginBottom: 20, marginBottom: 20,
}, },
@@ -517,10 +538,10 @@ const styles = StyleSheet.create({
}); });
// Helper functions // Helper functions
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number, currency = "USD") => {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency,
}).format(amount); }).format(amount);
}; };
@@ -565,212 +586,27 @@ const getStatusStyle = (status: string) => {
} }
}; };
// Helper function to estimate text height based on content and width function getColumnWidths(showRate: boolean) {
function estimateTextHeight( return showRate
text: string, ? {
maxWidth: number, date: "15%",
fontSize = 10, description: "40%",
lineHeight = 1.3, hours: "12%",
): number { rate: "15%",
if (!text) return fontSize * lineHeight; amount: "18%",
}
// Rough character width estimation for Helvetica at given font size : { date: "15%", description: "48%", hours: "14%", amount: "23%" };
const avgCharWidth = fontSize * 0.6;
const maxCharsPerLine = Math.floor(maxWidth / avgCharWidth);
if (maxCharsPerLine <= 0) return fontSize * lineHeight;
const lines = Math.ceil(text.length / maxCharsPerLine);
return lines * fontSize * lineHeight;
}
// Calculate estimated height for a table row based on actual content
function calculateRowHeight(
item: NonNullable<InvoiceData["items"]>[0],
): number {
if (!item) return 18; // fallback
const basePadding = 8; // Row padding
const fontSize = 10;
const lineHeight = 1.3;
// Description column is 40% of table width
// Table width is roughly 512 points (letter width - margins)
const descriptionWidth = 512 * 0.4;
const descriptionHeight = estimateTextHeight(
item.description,
descriptionWidth,
fontSize,
lineHeight,
);
// Minimum row height for other columns
const minRowHeight = fontSize * lineHeight;
// Row height is the maximum of description height and minimum height, plus padding
// Ensure minimum row height of 24 points for readability
return Math.max(descriptionHeight, minRowHeight, 24) + basePadding;
}
// Dynamic pagination calculation based on actual content
function calculateItemsForPage(
items: NonNullable<InvoiceData["items"]>,
startIndex: number,
isFirstPage: boolean,
hasNotes: boolean,
): number {
// Estimate available space in points (1 point = 1/72 inch)
const pageHeight = 792; // Letter size height in points
const margins = 80; // Top + bottom margins
const footerSpace = 60; // Footer space
let availableHeight = pageHeight - margins - footerSpace;
if (isFirstPage) {
// Dense header takes significant space
availableHeight -= 300; // Dense header space
} else {
// Abridged header is smaller
availableHeight -= 60; // Abridged header space
}
if (hasNotes) {
// Last page needs space for totals and notes
availableHeight -= 200; // Totals + notes space (much more conservative)
} else {
// Regular page just needs totals space
availableHeight -= 150; // Totals space only (much more conservative)
}
// Table header takes space
availableHeight -= 30; // Table header
// Calculate how many items can fit based on actual row heights
let usedHeight = 0;
let itemCount = 0;
for (let i = startIndex; i < items.length; i++) {
const item = items[i];
if (!item) continue;
const rowHeight = calculateRowHeight(item);
if (usedHeight + rowHeight > availableHeight) {
break; // This item won't fit
}
usedHeight += rowHeight;
itemCount++;
}
return Math.max(1, itemCount); // Always return at least 1 item
}
// Fallback function for backward compatibility
function calculateItemsPerPage(
isFirstPage: boolean,
hasNotes: boolean,
): number {
// Estimate available space in points (1 point = 1/72 inch)
const pageHeight = 792; // Letter size height in points
const margins = 80; // Top + bottom margins
const footerSpace = 60; // Footer space
let availableHeight = pageHeight - margins - footerSpace;
if (isFirstPage) {
// Dense header takes significant space
availableHeight -= 300; // Dense header space
} else {
// Abridged header is smaller
availableHeight -= 60; // Abridged header space
}
if (hasNotes) {
// Last page needs space for totals and notes
availableHeight -= 200; // Totals + notes space (much more conservative)
} else {
// Regular page just needs totals space
availableHeight -= 150; // Totals space only (much more conservative)
}
// Table header takes space
availableHeight -= 30; // Table header
// Conservative estimate using average row height
const avgRowHeight = 24; // Increased from 18 to account for potential wrapping
return Math.max(1, Math.floor(availableHeight / avgRowHeight));
}
// Dynamic pagination function
function paginateItems(
items: NonNullable<InvoiceData["items"]>,
hasNotes = false,
) {
const validItems = items.filter(Boolean);
const pages: Array<typeof validItems> = [];
if (validItems.length === 0) {
return [[]];
}
let currentIndex = 0;
let pageIndex = 0;
while (currentIndex < validItems.length) {
const isFirstPage = pageIndex === 0;
const remainingItems = validItems.length - currentIndex;
// Determine if this could be the last page with simple calculation
const maxPossibleItems = calculateItemsPerPage(isFirstPage, false);
const wouldBeLastPage =
currentIndex + maxPossibleItems >= validItems.length;
// Calculate items per page, accounting for notes space if this is likely the last page
let itemsPerPage = calculateItemsForPage(
validItems,
currentIndex,
isFirstPage,
wouldBeLastPage && hasNotes,
);
// Fallback to conservative calculation if dynamic fails
if (itemsPerPage === 0) {
itemsPerPage = calculateItemsPerPage(
isFirstPage,
wouldBeLastPage && hasNotes,
);
}
// Ensure we don't have tiny orphan pages
if (remainingItems > itemsPerPage && remainingItems - itemsPerPage < 2) {
itemsPerPage = Math.max(1, itemsPerPage - 1);
}
// Never take more items than we have
itemsPerPage = Math.min(itemsPerPage, remainingItems);
const pageItems = validItems.slice(
currentIndex,
currentIndex + itemsPerPage,
);
pages.push(pageItems);
currentIndex += itemsPerPage;
pageIndex++;
}
return pages;
} }
// Dense header component (first page) // Dense header component (first page)
const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => ( const DenseHeader: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => (
<View style={styles.denseHeader}> <View style={styles.denseHeader}>
<View style={styles.headerTop}> <View style={styles.headerTop}>
<View style={styles.businessSection}> <View style={styles.businessSection}>
<Text style={styles.businessName}> <Text style={[styles.businessName, { color: settings.pdfAccentColor }]}>
{invoice.business?.name ?? "Your Business Name"} {invoice.business?.name ?? "Your Business Name"}
</Text> </Text>
{invoice.business?.email && ( {invoice.business?.email && (
@@ -806,8 +642,13 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
</View> </View>
<View style={styles.invoiceSection}> <View style={styles.invoiceSection}>
<Text style={styles.invoiceTitle}>INVOICE</Text> <Text style={[styles.invoiceTitle, { color: settings.pdfAccentColor }]}>
<Text style={styles.invoiceNumber}>#{invoice.invoiceNumber}</Text> INVOICE
</Text>
<Text style={styles.invoiceNumber}>
{invoice.invoicePrefix ?? "#"}
{invoice.invoiceNumber}
</Text>
<View style={getStatusStyle(invoice.status)}> <View style={getStatusStyle(invoice.status)}>
<Text>{getStatusLabel(invoice.status)}</Text> <Text>{getStatusLabel(invoice.status)}</Text>
</View> </View>
@@ -865,33 +706,57 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
</View> </View>
); );
// Abridged header component (other pages)
const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
<View style={styles.abridgedHeader}>
<Text style={styles.abridgedBusinessName}>
{invoice.business?.name ?? "Your Business Name"}
</Text>
<View style={styles.abridgedInvoiceInfo}>
<Text style={styles.abridgedInvoiceTitle}>INVOICE</Text>
<Text style={styles.abridgedInvoiceNumber}>#{invoice.invoiceNumber}</Text>
</View>
</View>
);
// Table header component // Table header component
const TableHeader: React.FC = () => ( const TableHeader: React.FC<{
<View style={styles.tableHeader}> settings: Required<PDFGenerationSettings>;
<Text style={[styles.tableHeaderCell, styles.tableHeaderDate]}>Date</Text> showRate: boolean;
<Text style={[styles.tableHeaderCell, styles.tableHeaderDescription]}> }> = ({ settings, showRate }) => {
Description const cols = getColumnWidths(showRate);
</Text> return (
<Text style={[styles.tableHeaderCell, styles.tableHeaderHours]}>Hours</Text> <View
<Text style={[styles.tableHeaderCell, styles.tableHeaderRate]}>Rate</Text> style={[
<Text style={[styles.tableHeaderCell, styles.tableHeaderAmount]}> styles.tableHeader,
Amount settings.pdfTemplate === "minimal"
</Text> ? { backgroundColor: "#ffffff" }
</View> : {},
); ]}
>
<Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text>
<Text style={[styles.tableHeaderCell, { width: cols.description }]}>
Description
</Text>
<Text
style={[
styles.tableHeaderCell,
styles.tableHeaderHours,
{ width: cols.hours },
]}
>
Hours
</Text>
{showRate && (
<Text
style={[
styles.tableHeaderCell,
styles.tableHeaderRate,
{ width: cols.rate },
]}
>
Rate
</Text>
)}
<Text
style={[
styles.tableHeaderCell,
styles.tableHeaderAmount,
{ width: cols.amount },
]}
>
Amount
</Text>
</View>
);
};
// Footer component // Footer component
const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => { const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
@@ -907,35 +772,41 @@ const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
); );
}; };
const Footer: React.FC = () => ( const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
settings,
}) => (
<View style={styles.footer} fixed> <View style={styles.footer} fixed>
<View style={styles.footerLogo}> <View style={styles.footerLogo}>
{/* eslint-disable-next-line jsx-a11y/alt-text */} {settings.pdfShowLogo && (
<Image // eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image does not support alt.
src="/beenvoice-logo.png" <Image
style={{ src="/beenvoice-logo.png"
width: 120, style={{
height: 18, width: 120,
marginRight: 8, height: 18,
}} marginRight: 8,
/> }}
/>
)}
<Text <Text
style={{ style={{
fontSize: 9, fontSize: 9,
fontFamily: "Helvetica", fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
marginLeft: 8, marginLeft: settings.pdfShowLogo ? 8 : 0,
}} }}
> >
Professional Invoicing {settings.pdfFooterText}
</Text> </Text>
</View> </View>
<Text {settings.pdfShowPageNumbers && (
style={styles.pageNumber} <Text
render={({ pageNumber, totalPages }) => style={styles.pageNumber}
`Page ${pageNumber} of ${totalPages}` render={({ pageNumber, totalPages }) =>
} `Page ${pageNumber} of ${totalPages}`
/> }
/>
)}
</View> </View>
); );
@@ -943,13 +814,27 @@ const Footer: React.FC = () => (
const TotalsSection: React.FC<{ const TotalsSection: React.FC<{
invoice: InvoiceData; invoice: InvoiceData;
items: Array<NonNullable<InvoiceData["items"]>[0]>; items: Array<NonNullable<InvoiceData["items"]>[0]>;
}> = ({ invoice, items }) => { settings: Required<PDFGenerationSettings>;
}> = ({ invoice, items, settings }) => {
const currency = invoice.currency ?? "USD";
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0); const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
const taxAmount = (subtotal * invoice.taxRate) / 100; const taxAmount = (subtotal * invoice.taxRate) / 100;
const total = subtotal + taxAmount;
return ( return (
<View style={styles.totalsContainer}> <View style={styles.totalsContainer}>
<View style={styles.totalsBox}> <View
style={[
styles.totalsBox,
settings.pdfTemplate === "minimal"
? {
backgroundColor: "#ffffff",
borderTop: "1px solid #e5e7eb",
paddingHorizontal: 0,
}
: {},
]}
>
<Text <Text
style={{ style={{
fontSize: 11, fontSize: 11,
@@ -965,20 +850,29 @@ const TotalsSection: React.FC<{
<View style={styles.totalRow}> <View style={styles.totalRow}>
<Text style={styles.totalLabel}>Subtotal:</Text> <Text style={styles.totalLabel}>Subtotal:</Text>
<Text style={styles.totalAmount}>{formatCurrency(subtotal)}</Text> <Text style={styles.totalAmount}>
{formatCurrency(subtotal, currency)}
</Text>
</View> </View>
{invoice.taxRate > 0 && ( {invoice.taxRate > 0 && (
<View style={styles.totalRow}> <View style={styles.totalRow}>
<Text style={styles.totalLabel}>Tax ({invoice.taxRate}%):</Text> <Text style={styles.totalLabel}>Tax ({invoice.taxRate}%):</Text>
<Text style={styles.totalAmount}>{formatCurrency(taxAmount)}</Text> <Text style={styles.totalAmount}>
{formatCurrency(taxAmount, currency)}
</Text>
</View> </View>
)} )}
<View style={styles.finalTotalRow}> <View style={styles.finalTotalRow}>
<Text style={styles.finalTotalLabel}>TOTAL:</Text> <Text style={styles.finalTotalLabel}>TOTAL:</Text>
<Text style={styles.finalTotalAmount}> <Text
{formatCurrency(invoice.totalAmount)} style={[
styles.finalTotalAmount,
{ color: settings.pdfAccentColor },
]}
>
{formatCurrency(total, currency)}
</Text> </Text>
</View> </View>
@@ -991,87 +885,106 @@ const TotalsSection: React.FC<{
}; };
// Main PDF component // Main PDF component
const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => { export const InvoicePDF: React.FC<{
invoice: InvoiceData;
settings?: PDFGenerationSettings;
}> = ({ invoice, settings: inputSettings }) => {
const settings = resolvePDFSettings(inputSettings);
const items = invoice.items?.filter(Boolean) ?? []; const items = invoice.items?.filter(Boolean) ?? [];
const paginatedItems = paginateItems(items, Boolean(invoice.notes)); const currency = invoice.currency ?? "USD";
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
const cols = getColumnWidths(showRate);
return ( return (
<Document> <Document>
{paginatedItems.map((pageItems, pageIndex) => { <Page size="LETTER" style={styles.page}>
const isFirstPage = pageIndex === 0; <DenseHeader invoice={invoice} settings={settings} />
const isLastPage = pageIndex === paginatedItems.length - 1;
const hasItems = pageItems.length > 0;
return ( {items.length > 0 && (
<Page key={`page-${pageIndex}`} size="LETTER" style={styles.page}> <View style={styles.tableContainer}>
{/* Header */} <TableHeader settings={settings} showRate={showRate} />
{isFirstPage ? ( {items.map(
<DenseHeader invoice={invoice} /> (item, index) =>
) : ( item && (
<AbridgedHeader invoice={invoice} /> <View
)} key={`invoice-item-${index}`}
wrap={false}
{/* Table */} style={[
{hasItems && ( styles.tableRow,
<View style={styles.tableContainer}> settings.pdfTemplate === "classic" && index % 2 === 0
<TableHeader /> ? styles.tableRowAlt
{pageItems.map( : {},
(item, index) => ]}
item && ( >
<View <Text
key={`${pageIndex}-${index}`} style={[
styles.tableCell,
styles.tableCellDate,
{ width: cols.date },
]}
>
{formatDate(item.date)}
</Text>
<Text
style={[
styles.tableCell,
styles.tableCellDescription,
{ width: cols.description },
]}
>
{item.description}
</Text>
<Text
style={[
styles.tableCell,
styles.tableCellHours,
{ width: cols.hours },
]}
>
{item.hours}
</Text>
{showRate && (
<Text
style={[ style={[
styles.tableRow, styles.tableCell,
index % 2 === 0 ? styles.tableRowAlt : {}, styles.tableCellRate,
{ width: cols.rate },
]} ]}
> >
<Text style={[styles.tableCell, styles.tableCellDate]}> {formatCurrency(item.rate, currency)}
{formatDate(item.date)} </Text>
</Text> )}
<Text <Text
style={[ style={[
styles.tableCell, styles.tableCell,
styles.tableCellDescription, styles.tableCellAmount,
]} { width: cols.amount },
> ]}
{item.description} >
</Text> {formatCurrency(item.amount, currency)}
<Text style={[styles.tableCell, styles.tableCellHours]}> </Text>
{item.hours} </View>
</Text> ),
<Text style={[styles.tableCell, styles.tableCellRate]}>
{formatCurrency(item.rate)}
</Text>
<Text
style={[styles.tableCell, styles.tableCellAmount]}
>
{formatCurrency(item.amount)}
</Text>
</View>
),
)}
</View>
)} )}
</View>
)}
{/* Bottom section with notes and totals (only on last page) */} <View style={styles.bottomSection} wrap={false}>
{isLastPage && ( {invoice.notes && <NotesSection invoice={invoice} />}
<View style={styles.bottomSection}> <TotalsSection invoice={invoice} items={items} settings={settings} />
{invoice.notes && <NotesSection invoice={invoice} />} </View>
<TotalsSection invoice={invoice} items={items} />
</View>
)}
{/* Footer */} <Footer settings={settings} />
<Footer /> </Page>
</Page>
);
})}
</Document> </Document>
); );
}; };
// Export functions // Export functions
export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> { export async function generateInvoicePDF(
invoice: InvoiceData,
settings?: PDFGenerationSettings,
): Promise<void> {
try { try {
// Validate invoice data // Validate invoice data
if (!invoice) { if (!invoice) {
@@ -1087,7 +1000,9 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
} }
// Generate PDF blob // Generate PDF blob
const originalBlob = await pdf(<InvoicePDF invoice={invoice} />).toBlob(); const originalBlob = await pdf(
<InvoicePDF invoice={invoice} settings={settings} />,
).toBlob();
// Validate blob // Validate blob
if (!originalBlob || originalBlob.size === 0) { if (!originalBlob || originalBlob.size === 0) {
@@ -1113,6 +1028,7 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
// Additional utility function for generating PDF without downloading // Additional utility function for generating PDF without downloading
export async function generateInvoicePDFBlob( export async function generateInvoicePDFBlob(
invoice: InvoiceData, invoice: InvoiceData,
settings?: PDFGenerationSettings,
): Promise<Blob> { ): Promise<Blob> {
try { try {
// Validate invoice data // Validate invoice data
@@ -1129,7 +1045,9 @@ export async function generateInvoicePDFBlob(
} }
// Generate PDF blob // Generate PDF blob
const originalBlob = await pdf(<InvoicePDF invoice={invoice} />).toBlob(); const originalBlob = await pdf(
<InvoicePDF invoice={invoice} settings={settings} />,
).toBlob();
// Validate blob // Validate blob
if (!originalBlob || originalBlob.size === 0) { if (!originalBlob || originalBlob.size === 0) {
+6
View File
@@ -4,6 +4,12 @@ import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if (pathname === "/auth/register" && process.env.DISABLE_SIGNUPS === "true") {
const signInUrl = new URL("/auth/signin", request.url);
signInUrl.searchParams.set("signup", "disabled");
return NextResponse.redirect(signInUrl);
}
// Define public routes that don't require authentication // Define public routes that don't require authentication
const publicRoutes = ["/", "/auth/signin", "/auth/register"]; const publicRoutes = ["/", "/auth/signin", "/auth/register"];
+55 -11
View File
@@ -1,14 +1,37 @@
import { z } from "zod"; import { z } from "zod";
import { Resend } from "resend"; import { Resend } from "resend";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices } from "~/server/db/schema"; import { invoices, platformSettings } from "~/server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { env } from "~/env"; import { env } from "~/env";
import { generateInvoicePDFBlob } from "~/lib/pdf-export"; import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
// Default Resend instance - will be overridden if business has custom API key function plainTextToHtml(value: string) {
const defaultResend = new Resend(env.RESEND_API_KEY); return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\n/g, "<br>");
}
function normalizeEmailNoteHtml(value: string) {
const visibleText = value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;|\u00a0/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
return visibleText ? value.trim() : "";
}
export const emailRouter = createTRPCRouter({ export const emailRouter = createTRPCRouter({
sendInvoice: protectedProcedure sendInvoice: protectedProcedure
@@ -56,7 +79,19 @@ export const emailRouter = createTRPCRouter({
// Generate PDF for attachment // Generate PDF for attachment
let pdfBuffer: Buffer; let pdfBuffer: Buffer;
try { try {
const pdfBlob = await generateInvoicePDFBlob(invoice); const settings = await ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
});
const pdfBlob = await generateInvoicePDFBlob(invoice, {
pdfTemplate: settings?.pdfTemplate as
| "classic"
| "minimal"
| undefined,
pdfAccentColor: settings?.pdfAccentColor,
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
});
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer()); pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
// Validate PDF was generated successfully // Validate PDF was generated successfully
@@ -86,6 +121,12 @@ export const emailRouter = createTRPCRouter({
"Your Name"; "Your Name";
const userEmail = const userEmail =
invoice.business?.email ?? ctx.session.user?.email ?? ""; invoice.business?.email ?? ctx.session.user?.email ?? "";
const customMessage =
input.customMessage !== undefined
? normalizeEmailNoteHtml(input.customMessage)
: invoice.emailMessage
? plainTextToHtml(invoice.emailMessage)
: undefined;
// Generate branded email template // Generate branded email template
const emailTemplate = generateInvoiceEmailTemplate({ const emailTemplate = generateInvoiceEmailTemplate({
@@ -96,7 +137,7 @@ export const emailRouter = createTRPCRouter({
status: invoice.status, status: invoice.status,
totalAmount: invoice.totalAmount, totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate, taxRate: invoice.taxRate,
notes: invoice.notes, currency: invoice.currency,
client: { client: {
name: invoice.client.name, name: invoice.client.name,
email: invoice.client.email, email: invoice.client.email,
@@ -105,7 +146,7 @@ export const emailRouter = createTRPCRouter({
items: invoice.items, items: invoice.items,
}, },
customContent: input.customContent, customContent: input.customContent,
customMessage: input.customMessage, customMessage,
userName, userName,
userEmail, userEmail,
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000", baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
@@ -126,14 +167,17 @@ export const emailRouter = createTRPCRouter({
: invoice.business.name) ?? : invoice.business.name) ??
userName; userName;
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`; fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
} else if (env.RESEND_DOMAIN) { } else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) {
// Use system Resend configuration // Use system Resend configuration
resendInstance = defaultResend; resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = `noreply@${env.RESEND_DOMAIN}`; fromEmail = `noreply@${env.RESEND_DOMAIN}`;
} else if (env.RESEND_API_KEY) {
resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = invoice.business?.email ?? "noreply@example.com";
} else { } else {
// Fallback to business email if no configured domains throw new Error(
resendInstance = defaultResend; "Email delivery is not configured. Add a Resend API key globally or on this business.",
fromEmail = invoice.business?.email ?? "noreply@yourdomain.com"; );
} }
// Prepare CC and BCC lists // Prepare CC and BCC lists
+13 -21
View File
@@ -3,18 +3,9 @@ import { eq, and, desc } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { expenses, clients, businesses, invoices } from "~/server/db/schema"; import { expenses, clients, businesses, invoices } from "~/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
export const EXPENSE_CATEGORIES = [ export { EXPENSE_CATEGORIES };
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
const createExpenseSchema = z.object({ const createExpenseSchema = z.object({
date: z.date(), date: z.date(),
@@ -24,6 +15,7 @@ const createExpenseSchema = z.object({
category: z.string().optional().or(z.literal("")), category: z.string().optional().or(z.literal("")),
billable: z.boolean().default(false), billable: z.boolean().default(false),
reimbursable: z.boolean().default(false), reimbursable: z.boolean().default(false),
taxDeductible: z.boolean().default(false),
notes: z.string().optional().or(z.literal("")), notes: z.string().optional().or(z.literal("")),
clientId: z.string().optional().or(z.literal("")), clientId: z.string().optional().or(z.literal("")),
businessId: z.string().optional().or(z.literal("")), businessId: z.string().optional().or(z.literal("")),
@@ -66,11 +58,11 @@ export const expensesRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const clean = { const clean = {
...input, ...input,
clientId: input.clientId?.trim() || null, clientId: input.clientId?.trim() ?? null,
businessId: input.businessId?.trim() || null, businessId: input.businessId?.trim() ?? null,
invoiceId: input.invoiceId?.trim() || null, invoiceId: input.invoiceId?.trim() ?? null,
category: input.category?.trim() || null, category: input.category?.trim() ?? null,
notes: input.notes?.trim() || null, notes: input.notes?.trim() ?? null,
}; };
if (clean.clientId) { if (clean.clientId) {
@@ -129,11 +121,11 @@ export const expensesRouter = createTRPCRouter({
const clean = { const clean = {
...data, ...data,
clientId: data.clientId?.trim() || null, clientId: data.clientId?.trim() ?? null,
businessId: data.businessId?.trim() || null, businessId: data.businessId?.trim() ?? null,
invoiceId: data.invoiceId?.trim() || null, invoiceId: data.invoiceId?.trim() ?? null,
category: data.category?.trim() || null, category: data.category?.trim() ?? null,
notes: data.notes?.trim() || null, notes: data.notes?.trim() ?? null,
updatedAt: new Date(), updatedAt: new Date(),
}; };
+273 -164
View File
@@ -6,8 +6,16 @@ import {
invoiceItems, invoiceItems,
clients, clients,
businesses, businesses,
platformSettings,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import type { db } from "~/server/db";
type InvoiceRouterContext = {
db: typeof db;
session: { user: { id: string } };
};
const invoiceItemSchema = z.object({ const invoiceItemSchema = z.object({
date: z.date(), date: z.date(),
@@ -18,6 +26,7 @@ const invoiceItemSchema = z.object({
const createInvoiceSchema = z.object({ const createInvoiceSchema = z.object({
invoiceNumber: z.string().min(1, "Invoice number is required"), invoiceNumber: z.string().min(1, "Invoice number is required"),
invoicePrefix: z.string().optional().default("#"),
businessId: z businessId: z
.string() .string()
.min(1, "Business is required") .min(1, "Business is required")
@@ -28,6 +37,7 @@ const createInvoiceSchema = z.object({
dueDate: z.date(), dueDate: z.date(),
status: z.enum(["draft", "sent", "paid"]).default("draft"), status: z.enum(["draft", "sent", "paid"]).default("draft"),
notes: z.string().optional().or(z.literal("")), notes: z.string().optional().or(z.literal("")),
emailMessage: z.string().optional().or(z.literal("")),
taxRate: z.number().min(0).max(100).default(0), taxRate: z.number().min(0).max(100).default(0),
currency: z.string().length(3).default("USD"), currency: z.string().length(3).default("USD"),
items: z.array(invoiceItemSchema).min(1, "At least one item is required"), items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
@@ -42,6 +52,64 @@ const updateStatusSchema = z.object({
status: z.enum(["draft", "sent", "paid"]), status: z.enum(["draft", "sent", "paid"]),
}); });
async function verifyBusinessAccess(
ctx: InvoiceRouterContext,
businessId?: string | null,
) {
if (!businessId) return null;
const business = await ctx.db.query.businesses.findFirst({
where: eq(businesses.id, businessId),
});
if (!business) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Business not found",
});
}
if (business.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to use this business",
});
}
return business;
}
async function verifyClientAccess(ctx: InvoiceRouterContext, clientId: string) {
const client = await ctx.db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Client not found",
});
}
if (client.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to use this client",
});
}
return client;
}
const calculateInvoiceTotal = (
items: Array<z.infer<typeof invoiceItemSchema>>,
taxRate: number,
) => {
const subtotal = items.reduce((sum, item) => sum + item.hours * item.rate, 0);
const taxAmount = (subtotal * taxRate) / 100;
return subtotal + taxAmount;
};
export const invoicesRouter = createTRPCRouter({ export const invoicesRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => { getAll: protectedProcedure.query(async ({ ctx }) => {
try { try {
@@ -139,85 +207,56 @@ export const invoicesRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { try {
const { items, ...invoiceData } = input; const { items, ...invoiceData } = input;
const cleanInvoiceData = {
...invoiceData,
businessId:
!invoiceData.businessId || invoiceData.businessId.trim() === ""
? null
: invoiceData.businessId,
notes: invoiceData.notes === "" ? null : invoiceData.notes,
emailMessage:
invoiceData.emailMessage === "" ? null : invoiceData.emailMessage,
};
// Verify business exists and belongs to user (if provided) // Verify business exists and belongs to user (if provided)
if (invoiceData.businessId && invoiceData.businessId.trim() !== "") { await verifyBusinessAccess(ctx, cleanInvoiceData.businessId);
const business = await ctx.db.query.businesses.findFirst({
where: eq(businesses.id, invoiceData.businessId),
});
if (!business) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Business not found",
});
}
if (business.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"You don't have permission to create invoices for this business",
});
}
}
// Verify client exists and belongs to user // Verify client exists and belongs to user
const client = await ctx.db.query.clients.findFirst({ await verifyClientAccess(ctx, cleanInvoiceData.clientId);
where: eq(clients.id, invoiceData.clientId),
});
if (!client) { const totalAmount = calculateInvoiceTotal(
throw new TRPCError({ items,
code: "BAD_REQUEST", cleanInvoiceData.taxRate,
message: "Client not found",
});
}
if (client.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"You don't have permission to create invoices for this client",
});
}
// Calculate subtotal and tax
const subtotal = items.reduce(
(sum, item) => sum + item.hours * item.rate,
0,
); );
const taxAmount = (subtotal * invoiceData.taxRate) / 100;
const totalAmount = subtotal + taxAmount;
// Create invoice return await ctx.db.transaction(async (tx) => {
const [invoice] = await ctx.db const [invoice] = await tx
.insert(invoices) .insert(invoices)
.values({ .values({
...invoiceData, ...cleanInvoiceData,
totalAmount, totalAmount,
createdById: ctx.session.user.id, createdById: ctx.session.user.id,
}) })
.returning(); .returning();
if (!invoice) { if (!invoice) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to create invoice", message: "Failed to create invoice",
}); });
} }
// Create invoice items await tx.insert(invoiceItems).values(
const itemsToInsert = items.map((item, idx) => ({ items.map((item, idx) => ({
...item, ...item,
invoiceId: invoice.id, invoiceId: invoice.id,
amount: item.hours * item.rate, amount: item.hours * item.rate,
position: idx, position: idx,
})); })),
);
await ctx.db.insert(invoiceItems).values(itemsToInsert); return invoice;
});
return invoice;
} catch (error) { } catch (error) {
if (error instanceof TRPCError) throw error; if (error instanceof TRPCError) throw error;
throw new TRPCError({ throw new TRPCError({
@@ -237,11 +276,25 @@ export const invoicesRouter = createTRPCRouter({
// Clean up empty strings to null for optional string fields only // Clean up empty strings to null for optional string fields only
const cleanInvoiceData = { const cleanInvoiceData = {
...invoiceData, ...invoiceData,
businessId: ...(invoiceData.businessId !== undefined
!invoiceData.businessId || invoiceData.businessId.trim() === "" ? {
? null businessId:
: invoiceData.businessId, invoiceData.businessId.trim() === ""
notes: invoiceData.notes === "" ? null : invoiceData.notes, ? null
: invoiceData.businessId,
}
: {}),
...(invoiceData.notes !== undefined
? { notes: invoiceData.notes === "" ? null : invoiceData.notes }
: {}),
...(invoiceData.emailMessage !== undefined
? {
emailMessage:
invoiceData.emailMessage === ""
? null
: invoiceData.emailMessage,
}
: {}),
}; };
// Verify invoice exists and belongs to user // Verify invoice exists and belongs to user
@@ -268,96 +321,66 @@ export const invoicesRouter = createTRPCRouter({
cleanInvoiceData.businessId && cleanInvoiceData.businessId &&
cleanInvoiceData.businessId.trim() !== "" cleanInvoiceData.businessId.trim() !== ""
) { ) {
const business = await ctx.db.query.businesses.findFirst({ await verifyBusinessAccess(ctx, cleanInvoiceData.businessId);
where: eq(businesses.id, cleanInvoiceData.businessId),
});
if (!business || business.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to use this business",
});
}
} }
// If client is being updated, verify it belongs to user // If client is being updated, verify it belongs to user
if (cleanInvoiceData.clientId) { if (cleanInvoiceData.clientId) {
const client = await ctx.db.query.clients.findFirst({ await verifyClientAccess(ctx, cleanInvoiceData.clientId);
where: eq(clients.id, cleanInvoiceData.clientId),
});
if (!client || client.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to use this client",
});
}
} }
if (items) { await ctx.db.transaction(async (tx) => {
// Calculate subtotal and tax if (items) {
const subtotal = items.reduce( const totalAmount = calculateInvoiceTotal(
(sum, item) => sum + item.hours * item.rate, items,
0, cleanInvoiceData.taxRate ?? existingInvoice.taxRate,
); );
const taxAmount =
(subtotal * (cleanInvoiceData.taxRate ?? existingInvoice.taxRate)) /
100;
const totalAmount = subtotal + taxAmount;
// Update invoice const [updatedInvoice] = await tx
const updateData = { .update(invoices)
...cleanInvoiceData, .set({
totalAmount, ...cleanInvoiceData,
updatedAt: new Date(), totalAmount,
}; updatedAt: new Date(),
})
.where(eq(invoices.id, id))
.returning();
const [updatedInvoice] = await ctx.db if (!updatedInvoice) {
.update(invoices) throw new TRPCError({
.set(updateData) code: "INTERNAL_SERVER_ERROR",
.where(eq(invoices.id, id)) message: "Failed to update invoice",
.returning(); });
}
if (!updatedInvoice) { await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", await tx.insert(invoiceItems).values(
message: "Failed to update invoice", items.map((item, idx) => ({
}); ...item,
invoiceId: id,
amount: item.hours * item.rate,
position: idx,
})),
);
} else {
const [updatedInvoice] = await tx
.update(invoices)
.set({
...cleanInvoiceData,
updatedAt: new Date(),
})
.where(eq(invoices.id, id))
.returning();
if (!updatedInvoice) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update invoice",
});
}
} }
});
// Delete existing items and create new ones
await ctx.db
.delete(invoiceItems)
.where(eq(invoiceItems.invoiceId, id));
const itemsToInsert = items.map((item, idx) => ({
...item,
invoiceId: id,
amount: item.hours * item.rate,
position: idx,
}));
await ctx.db.insert(invoiceItems).values(itemsToInsert);
} else {
// Update invoice without items
const updateData = {
...cleanInvoiceData,
updatedAt: new Date(),
};
const [updatedInvoice] = await ctx.db
.update(invoices)
.set(updateData)
.where(eq(invoices.id, id))
.returning();
if (!updatedInvoice) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update invoice",
});
}
}
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@@ -416,11 +439,17 @@ export const invoicesRouter = createTRPCRouter({
}); });
if (!invoice) { if (!invoice) {
throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "Invoice not found",
});
} }
if (invoice.createdById !== ctx.session.user.id) { if (invoice.createdById !== ctx.session.user.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "You don't have permission to update this invoice" }); throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to update this invoice",
});
} }
await ctx.db await ctx.db
@@ -428,18 +457,27 @@ export const invoicesRouter = createTRPCRouter({
.set({ status: input.status, updatedAt: new Date() }) .set({ status: input.status, updatedAt: new Date() })
.where(eq(invoices.id, input.id)); .where(eq(invoices.id, input.id));
return { success: true, message: `Invoice status updated to ${input.status}` }; return {
success: true,
message: `Invoice status updated to ${input.status}`,
};
} catch (error) { } catch (error) {
if (error instanceof TRPCError) throw error; if (error instanceof TRPCError) throw error;
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to update invoice status", cause: error }); throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update invoice status",
cause: error,
});
} }
}), }),
bulkUpdateStatus: protectedProcedure bulkUpdateStatus: protectedProcedure
.input(z.object({ .input(
ids: z.array(z.string()).min(1), z.object({
status: z.enum(["draft", "sent", "paid"]), ids: z.array(z.string()).min(1),
})) status: z.enum(["draft", "sent", "paid"]),
}),
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Only update invoices owned by this user // Only update invoices owned by this user
const owned = await ctx.db.query.invoices.findMany({ const owned = await ctx.db.query.invoices.findMany({
@@ -452,7 +490,10 @@ export const invoicesRouter = createTRPCRouter({
.map((inv) => inv.id); .map((inv) => inv.id);
if (ownedIds.length === 0) { if (ownedIds.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "No matching invoices found",
});
} }
await ctx.db await ctx.db
@@ -476,11 +517,79 @@ export const invoicesRouter = createTRPCRouter({
.map((inv) => inv.id); .map((inv) => inv.id);
if (ownedIds.length === 0) { if (ownedIds.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "No matching invoices found",
});
} }
await ctx.db.delete(invoices).where(inArray(invoices.id, ownedIds)); await ctx.db.delete(invoices).where(inArray(invoices.id, ownedIds));
return { success: true, deleted: ownedIds.length }; return { success: true, deleted: ownedIds.length };
}), }),
previewPdf: protectedProcedure
.input(createInvoiceSchema)
.query(async ({ ctx, input }) => {
try {
const businessId =
input.businessId && input.businessId.trim() !== ""
? input.businessId
: null;
const [client, business, settings] = await Promise.all([
verifyClientAccess(ctx, input.clientId),
verifyBusinessAccess(ctx, businessId),
ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
}),
]);
const totalAmount = calculateInvoiceTotal(input.items, input.taxRate);
const pdfBlob = await generateInvoicePDFBlob(
{
invoiceNumber: input.invoiceNumber,
invoicePrefix: input.invoicePrefix,
issueDate: input.issueDate,
dueDate: input.dueDate,
status: input.status,
totalAmount,
taxRate: input.taxRate,
currency: input.currency,
notes: input.notes,
client,
business,
items: input.items.map((item) => ({
date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.hours * item.rate,
})),
},
{
pdfTemplate: settings?.pdfTemplate as
| "classic"
| "minimal"
| undefined,
pdfAccentColor: settings?.pdfAccentColor,
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
},
);
const buffer = Buffer.from(await pdfBlob.arrayBuffer());
return {
contentType: "application/pdf",
base64: buffer.toString("base64"),
};
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate PDF preview",
cause: error,
});
}
}),
}); });
+229 -27
View File
@@ -1,14 +1,50 @@
import { z } from "zod"; import { z } from "zod";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc";
import { import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "~/server/api/trpc";
import {
accounts,
users, users,
clients, clients,
businesses, businesses,
invoices, invoices,
invoiceItems, invoiceItems,
platformSettings,
} from "~/server/db/schema"; } from "~/server/db/schema";
import {
defaultBodyFontPreference,
defaultFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/branding";
import type { db as database } from "~/server/db";
async function requireAdmin(ctx: {
db: typeof database;
session: { user: { id: string } };
}) {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: { role: true },
});
if (user?.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
// Validation schemas for backup data // Validation schemas for backup data
const ClientBackupSchema = z.object({ const ClientBackupSchema = z.object({
@@ -60,6 +96,7 @@ const InvoiceBackupSchema = z.object({
totalAmount: z.number().default(0), totalAmount: z.number().default(0),
taxRate: z.number().default(0), taxRate: z.number().default(0),
notes: z.string().optional(), notes: z.string().optional(),
emailMessage: z.string().optional(),
items: z.array(InvoiceItemBackupSchema), items: z.array(InvoiceItemBackupSchema),
}); });
@@ -76,6 +113,37 @@ const BackupDataSchema = z.object({
}); });
export const settingsRouter = createTRPCRouter({ export const settingsRouter = createTRPCRouter({
listAccounts: protectedProcedure.query(async ({ ctx }) => {
await requireAdmin(ctx);
return ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
email: true,
role: true,
emailVerified: true,
createdAt: true,
},
orderBy: (users, { asc }) => [asc(users.createdAt)],
});
}),
updateAccountRole: protectedProcedure
.input(
z.object({
userId: z.string().min(1),
role: z.enum(["user", "admin"]),
}),
)
.mutation(async ({ ctx, input }) => {
await requireAdmin(ctx);
await ctx.db
.update(users)
.set({ role: input.role })
.where(eq(users.id, input.userId));
return { success: true };
}),
// Get user profile information // Get user profile information
getProfile: protectedProcedure.query(async ({ ctx }) => { getProfile: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.query.users.findFirst({ const user = await ctx.db.query.users.findFirst({
@@ -85,6 +153,7 @@ export const settingsRouter = createTRPCRouter({
name: true, name: true,
email: true, email: true,
image: true, image: true,
role: true,
}, },
}); });
@@ -144,20 +213,41 @@ export const settingsRouter = createTRPCRouter({
}), }),
// Get theme preferences // Get theme preferences
getTheme: protectedProcedure.query(async ({ ctx }) => { getTheme: publicProcedure.query(async ({ ctx }) => {
const user = await ctx.db.query.users.findFirst({ const settings = await ctx.db.query.platformSettings.findFirst({
where: eq(users.id, ctx.session.user.id), where: eq(platformSettings.id, "global"),
columns: {
colorTheme: true,
customColor: true,
theme: true,
},
}); });
return { return {
colorTheme: (user?.colorTheme as "slate" | "blue" | "green" | "rose" | "orange" | "custom") ?? "slate", colorTheme: (settings?.colorTheme as ColorTheme) ?? "slate",
customColor: user?.customColor ?? undefined, customColor: settings?.customColor ?? undefined,
theme: (user?.theme as "light" | "dark" | "system") ?? "system", theme: (settings?.theme as ColorMode) ?? "system",
interfaceTheme:
(settings?.interfaceTheme as InterfaceTheme) ?? defaultInterfaceTheme,
fontPreference: defaultFontPreference,
bodyFontPreference:
(settings?.bodyFontPreference as FontPreference) ??
defaultBodyFontPreference,
headingFontPreference:
(settings?.headingFontPreference as FontPreference) ??
defaultHeadingFontPreference,
radiusPreference:
(settings?.radiusPreference as RadiusPreference) ??
defaultRadiusPreference,
sidebarStyle:
(settings?.sidebarStyle as SidebarStyle) ?? defaultSidebarStyle,
brandName: settings?.brandName ?? "beenvoice",
brandTagline:
settings?.brandTagline ??
"Simple and efficient invoicing for freelancers and small businesses",
brandLogoText: settings?.brandLogoText ?? "beenvoice",
brandIcon: settings?.brandIcon ?? "$",
pdfTemplate:
(settings?.pdfTemplate as "classic" | "minimal") ?? "classic",
pdfAccentColor: settings?.pdfAccentColor ?? "#111827",
pdfFooterText: settings?.pdfFooterText ?? "Professional Invoicing",
pdfShowLogo: settings?.pdfShowLogo ?? true,
pdfShowPageNumbers: settings?.pdfShowPageNumbers ?? true,
}; };
}), }),
@@ -165,20 +255,105 @@ export const settingsRouter = createTRPCRouter({
updateTheme: protectedProcedure updateTheme: protectedProcedure
.input( .input(
z.object({ z.object({
colorTheme: z.enum(["slate", "blue", "green", "rose", "orange", "custom"]).optional(), colorTheme: z
.enum(["slate", "blue", "green", "rose", "orange", "custom"])
.optional(),
customColor: z.string().optional(), customColor: z.string().optional(),
theme: z.enum(["light", "dark", "system"]).optional(), theme: z.enum(["light", "dark", "system"]).optional(),
interfaceTheme: z
.enum(["beenvoice", "shadcn", "minimal", "editorial"])
.optional(),
fontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
bodyFontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
headingFontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
radiusPreference: z.enum(["none", "sm", "md", "lg", "xl"]).optional(),
sidebarStyle: z.enum(["floating", "docked"]).optional(),
brandName: z.string().min(1).max(100).optional(),
brandTagline: z.string().min(1).max(255).optional(),
brandLogoText: z.string().min(1).max(100).optional(),
brandIcon: z.string().min(1).max(20).optional(),
pdfTemplate: z.enum(["classic", "minimal"]).optional(),
pdfAccentColor: z.string().min(4).max(50).optional(),
pdfFooterText: z.string().min(1).max(120).optional(),
pdfShowLogo: z.boolean().optional(),
pdfShowPageNumbers: z.boolean().optional(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await requireAdmin(ctx);
await ctx.db await ctx.db
.update(users) .insert(platformSettings)
.set({ .values({
...(input.colorTheme && { colorTheme: input.colorTheme }), id: "global",
...(input.customColor !== undefined && { customColor: input.customColor }), brandName: input.brandName ?? "beenvoice",
...(input.theme && { theme: input.theme }), brandTagline:
input.brandTagline ??
"Simple and efficient invoicing for freelancers and small businesses",
brandLogoText: input.brandLogoText ?? "beenvoice",
brandIcon: input.brandIcon ?? "$",
colorTheme: input.colorTheme ?? "slate",
customColor: input.customColor,
theme: input.theme ?? "system",
interfaceTheme: input.interfaceTheme ?? defaultInterfaceTheme,
bodyFontPreference:
input.bodyFontPreference ?? defaultBodyFontPreference,
headingFontPreference:
input.headingFontPreference ?? defaultHeadingFontPreference,
radiusPreference: input.radiusPreference ?? defaultRadiusPreference,
sidebarStyle: input.sidebarStyle ?? defaultSidebarStyle,
pdfTemplate: input.pdfTemplate ?? "classic",
pdfAccentColor: input.pdfAccentColor ?? "#111827",
pdfFooterText: input.pdfFooterText ?? "Professional Invoicing",
pdfShowLogo: input.pdfShowLogo ?? true,
pdfShowPageNumbers: input.pdfShowPageNumbers ?? true,
}) })
.where(eq(users.id, ctx.session.user.id)); .onConflictDoUpdate({
target: platformSettings.id,
set: {
...(input.brandName && { brandName: input.brandName }),
...(input.brandTagline && { brandTagline: input.brandTagline }),
...(input.brandLogoText && {
brandLogoText: input.brandLogoText,
}),
...(input.brandIcon && { brandIcon: input.brandIcon }),
...(input.colorTheme && { colorTheme: input.colorTheme }),
...(input.customColor !== undefined && {
customColor: input.customColor,
}),
...(input.theme && { theme: input.theme }),
...(input.interfaceTheme && {
interfaceTheme: input.interfaceTheme,
}),
...(input.bodyFontPreference && {
bodyFontPreference: input.bodyFontPreference,
}),
...(input.headingFontPreference && {
headingFontPreference: input.headingFontPreference,
}),
...(input.radiusPreference && {
radiusPreference: input.radiusPreference,
}),
...(input.sidebarStyle && { sidebarStyle: input.sidebarStyle }),
...(input.pdfTemplate && { pdfTemplate: input.pdfTemplate }),
...(input.pdfAccentColor && {
pdfAccentColor: input.pdfAccentColor,
}),
...(input.pdfFooterText && { pdfFooterText: input.pdfFooterText }),
...(input.pdfShowLogo !== undefined && {
pdfShowLogo: input.pdfShowLogo,
}),
...(input.pdfShowPageNumbers !== undefined && {
pdfShowPageNumbers: input.pdfShowPageNumbers,
}),
updatedAt: new Date(),
},
});
return { success: true }; return { success: true };
}), }),
@@ -252,13 +427,38 @@ export const settingsRouter = createTRPCRouter({
saltRounds, saltRounds,
); );
// Update the password await ctx.db.transaction(async (tx) => {
await ctx.db await tx
.update(users) .update(users)
.set({ .set({
password: hashedNewPassword, password: hashedNewPassword,
}) })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
const credentialAccount = await tx.query.accounts.findFirst({
where: and(
eq(accounts.userId, userId),
eq(accounts.providerId, "credential"),
),
});
if (credentialAccount) {
await tx
.update(accounts)
.set({
password: hashedNewPassword,
updatedAt: new Date(),
})
.where(eq(accounts.id, credentialAccount.id));
} else {
await tx.insert(accounts).values({
userId,
accountId: userId,
providerId: "credential",
password: hashedNewPassword,
});
}
});
return { success: true }; return { success: true };
}), }),
@@ -390,6 +590,7 @@ export const settingsRouter = createTRPCRouter({
totalAmount: invoice.totalAmount, totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate, taxRate: invoice.taxRate,
notes: invoice.notes ?? undefined, notes: invoice.notes ?? undefined,
emailMessage: invoice.emailMessage ?? undefined,
items: invoice.items, items: invoice.items,
})), })),
}; };
@@ -469,6 +670,7 @@ export const settingsRouter = createTRPCRouter({
totalAmount: invoiceData.totalAmount, totalAmount: invoiceData.totalAmount,
taxRate: invoiceData.taxRate, taxRate: invoiceData.taxRate,
notes: invoiceData.notes, notes: invoiceData.notes,
emailMessage: invoiceData.emailMessage,
createdById: userId, createdById: userId,
}) })
.returning({ id: invoices.id }); .returning({ id: invoices.id });
+229 -4
View File
@@ -6,6 +6,10 @@
* This applies any pending migrations from the drizzle/ directory to the * This applies any pending migrations from the drizzle/ directory to the
* database specified by DATABASE_URL. It is safe to run multiple times — * database specified by DATABASE_URL. It is safe to run multiple times —
* Drizzle tracks applied migrations in the __drizzle_migrations table. * Drizzle tracks applied migrations in the __drizzle_migrations table.
*
* If the database was previously set up via `db:push` (no migration history),
* this script will baseline it: seed the migration history without re-running
* the SQL, so only future migrations are applied.
*/ */
import * as dotenv from "dotenv"; import * as dotenv from "dotenv";
@@ -17,7 +21,8 @@ import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres"; import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator"; import { migrate } from "drizzle-orm/node-postgres/migrator";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import fs from "fs";
import crypto from "crypto";
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) { if (!databaseUrl) {
@@ -25,20 +30,240 @@ if (!databaseUrl) {
process.exit(1); process.exit(1);
} }
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const migrationsFolder = path.resolve(process.cwd(), "drizzle");
const migrationsFolder = path.resolve(__dirname, "../../../drizzle");
const pool = new Pool({ const pool = new Pool({
connectionString: databaseUrl, connectionString: databaseUrl,
ssl: process.env.DB_DISABLE_SSL === "true" ? false : { rejectUnauthorized: false }, ssl:
process.env.DB_DISABLE_SSL === "true"
? false
: { rejectUnauthorized: false },
max: 1, max: 1,
}); });
const db = drizzle(pool); const db = drizzle(pool);
/**
* Verify and repair the migration tracking table:
* 1. If no tracking table exists and DB has tables → baseline from db:push
* 2. If tracking table exists → scan for any entries that are recorded as
* applied but whose schema changes don't actually exist, and remove them
* so migrate() will re-run those migrations.
*/
async function baselineIfNeeded(client: Pool) {
const hasMigrationsTable = await tableExists(
client,
"drizzle",
"__drizzle_migrations",
);
// Always ensure the drizzle schema + table exist
await client.query(`CREATE SCHEMA IF NOT EXISTS drizzle`);
await client.query(`
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at bigint
)
`);
const { rows: entryRows } = await client.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations`,
);
const hasEntries = parseInt(entryRows[0]?.count ?? "0") > 0;
if (!hasMigrationsTable || !hasEntries) {
// No history at all — check if DB was previously set up via db:push
const dbAlreadyExists = await tableExists(
client,
"public",
"beenvoice_account",
);
if (!dbAlreadyExists) {
return; // Fresh DB — let migrate() run everything normally
}
console.log(
"[migrate] Existing database detected without migration history — baselining...",
);
await seedMigrationHistory(client);
return;
}
// Migration history exists — validate that each recorded migration is
// actually reflected in the schema. Remove any bogus entries.
await removeBogusEntries(client);
}
async function seedMigrationHistory(client: Pool) {
const journal = JSON.parse(
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8"),
) as { entries: { idx: number; tag: string; when: number }[] };
for (const entry of journal.entries) {
const applied = await isMigrationApplied(client, entry.tag);
if (!applied) {
console.log(`[migrate] Not yet in schema, will run: ${entry.tag}`);
continue;
}
const sql = fs.readFileSync(
path.join(migrationsFolder, `${entry.tag}.sql`),
"utf8",
);
const hash = crypto.createHash("sha256").update(sql).digest("hex");
await client.query(
`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)`,
[hash, entry.when],
);
console.log(`[migrate] Baselined: ${entry.tag}`);
}
console.log("[migrate] Baseline complete");
}
async function removeBogusEntries(client: Pool) {
// Get all recorded hashes
const { rows } = await client.query<{ id: number; hash: string }>(
`SELECT id, hash FROM drizzle.__drizzle_migrations ORDER BY id`,
);
const journal = JSON.parse(
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8"),
) as { entries: { idx: number; tag: string; when: number }[] };
for (const entry of journal.entries) {
const sql = fs.readFileSync(
path.join(migrationsFolder, `${entry.tag}.sql`),
"utf8",
);
const expectedHash = crypto.createHash("sha256").update(sql).digest("hex");
const recorded = rows.find((r) => r.hash === expectedHash);
if (!recorded) continue; // Not recorded yet — migrate() will run it
// It's recorded — verify it's actually applied in the schema
const applied = await isMigrationApplied(client, entry.tag);
if (!applied) {
console.log(
`[migrate] Removing bogus migration record for: ${entry.tag}`,
);
await client.query(
`DELETE FROM drizzle.__drizzle_migrations WHERE id = $1`,
[recorded.id],
);
}
}
}
async function tableExists(
client: Pool,
schema: string,
table: string,
): Promise<boolean> {
const { rows } = await client.query<{ count: string }>(
`
SELECT COUNT(*)::text AS count FROM information_schema.tables
WHERE table_schema = $1 AND table_name = $2
`,
[schema, table],
);
return parseInt(rows[0]?.count ?? "0") > 0;
}
/**
* Check whether a specific migration's schema changes already exist in the DB.
*/
async function isMigrationApplied(client: Pool, tag: string): Promise<boolean> {
if (tag === "0000_glossy_magneto") {
return tableExists(client, "public", "beenvoice_account");
}
if (tag === "0001_supreme_the_enforcers") {
// 0001 adds currency to beenvoice_client
const { rows } = await client.query<{ count: string }>(`
SELECT COUNT(*)::text AS count FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'beenvoice_client'
AND column_name = 'currency'
`);
return parseInt(rows[0]?.count ?? "0") > 0;
}
if (tag === "0002_tax_deductible") {
// 0002 adds taxDeductible to beenvoice_expense
const { rows } = await client.query<{ count: string }>(`
SELECT COUNT(*)::text AS count FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'beenvoice_expense'
AND column_name = 'taxDeductible'
`);
return parseInt(rows[0]?.count ?? "0") > 0;
}
if (tag === "0003_appearance_preferences") {
// 0003 adds appearance preferences to beenvoice_user
const { rows } = await client.query<{ count: string }>(`
SELECT COUNT(*)::text AS count FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'beenvoice_user'
AND column_name = 'interfaceTheme'
`);
return parseInt(rows[0]?.count ?? "0") > 0;
}
if (tag === "0004_platform_appearance_controls") {
// 0004 adds platform-level appearance controls to beenvoice_user
const { rows } = await client.query<{ count: string }>(`
SELECT COUNT(*)::text AS count FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'beenvoice_user'
AND column_name = 'sidebarStyle'
`);
return parseInt(rows[0]?.count ?? "0") > 0;
}
if (tag === "0005_platform_settings_and_roles") {
const hasRole = await columnExists(
client,
"public",
"beenvoice_user",
"role",
);
const hasPlatformSettings = await tableExists(
client,
"public",
"beenvoice_platform_setting",
);
return hasRole && hasPlatformSettings;
}
if (tag === "0006_pdf_generation_settings") {
return columnExists(
client,
"public",
"beenvoice_platform_setting",
"pdfTemplate",
);
}
if (tag === "0007_invoice_email_message") {
return columnExists(client, "public", "beenvoice_invoice", "emailMessage");
}
// Unknown migration — assume not applied so it runs
return false;
}
async function columnExists(
client: Pool,
schema: string,
table: string,
column: string,
): Promise<boolean> {
const { rows } = await client.query<{ count: string }>(
`
SELECT COUNT(*)::text AS count FROM information_schema.columns
WHERE table_schema = $1 AND table_name = $2 AND column_name = $3
`,
[schema, table, column],
);
return parseInt(rows[0]?.count ?? "0") > 0;
}
console.log("[migrate] Running migrations from", migrationsFolder); console.log("[migrate] Running migrations from", migrationsFolder);
try { try {
await baselineIfNeeded(pool);
await migrate(db, { migrationsFolder }); await migrate(db, { migrationsFolder });
console.log("[migrate] All migrations applied successfully"); console.log("[migrate] All migrations applied successfully");
} catch (err) { } catch (err) {
+95 -15
View File
@@ -1,7 +1,6 @@
import { relations, sql } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
import { index, pgTableCreator } from "drizzle-orm/pg-core"; import { index, pgTableCreator } from "drizzle-orm/pg-core";
/** /**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same * This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects. * database instance for multiple projects.
@@ -22,7 +21,11 @@ export const users = createTable("user", (d) => ({
emailVerified: d.boolean().default(false).notNull(), emailVerified: d.boolean().default(false).notNull(),
image: d.varchar({ length: 255 }), image: d.varchar({ length: 255 }),
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()), updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
password: d.varchar({ length: 255 }), // Matched DB: varchar(255) password: d.varchar({ length: 255 }), // Matched DB: varchar(255)
resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255) resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255)
resetTokenExpiry: d.timestamp(), resetTokenExpiry: d.timestamp(),
@@ -32,6 +35,48 @@ export const users = createTable("user", (d) => ({
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(), colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
customColor: d.varchar({ length: 50 }), customColor: d.varchar({ length: 50 }),
theme: d.varchar({ length: 20 }).default("system").notNull(), theme: d.varchar({ length: 20 }).default("system").notNull(),
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
fontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
role: d.varchar({ length: 20 }).default("user").notNull(),
}));
export const platformSettings = createTable("platform_setting", (d) => ({
id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"),
brandName: d.varchar({ length: 100 }).default("beenvoice").notNull(),
brandTagline: d
.varchar({ length: 255 })
.default(
"Simple and efficient invoicing for freelancers and small businesses",
)
.notNull(),
brandLogoText: d.varchar({ length: 100 }).default("beenvoice").notNull(),
brandIcon: d.varchar({ length: 20 }).default("$").notNull(),
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
customColor: d.varchar({ length: 50 }),
theme: d.varchar({ length: 20 }).default("system").notNull(),
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
pdfTemplate: d.varchar({ length: 20 }).default("classic").notNull(),
pdfAccentColor: d.varchar({ length: 50 }).default("#111827").notNull(),
pdfFooterText: d
.varchar({ length: 120 })
.default("Professional Invoicing")
.notNull(),
pdfShowLogo: d.boolean().default(true).notNull(),
pdfShowPageNumbers: d.boolean().default(true).notNull(),
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
})); }));
export const usersRelations = relations(users, ({ many }) => ({ export const usersRelations = relations(users, ({ many }) => ({
@@ -47,7 +92,11 @@ export const usersRelations = relations(users, ({ many }) => ({
export const accounts = createTable( export const accounts = createTable(
"account", "account",
(d) => ({ (d) => ({
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text id: d
.text()
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
userId: d userId: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
@@ -62,11 +111,13 @@ export const accounts = createTable(
idToken: d.text(), idToken: d.text(),
password: d.text(), // Matched DB: text password: d.text(), // Matched DB: text
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()), updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
}), }),
(t) => [ (t) => [index("account_userId_idx").on(t.userId)],
index("account_userId_idx").on(t.userId),
],
); );
export const accountsRelations = relations(accounts, ({ one }) => ({ export const accountsRelations = relations(accounts, ({ one }) => ({
@@ -76,7 +127,11 @@ export const accountsRelations = relations(accounts, ({ one }) => ({
export const sessions = createTable( export const sessions = createTable(
"session", "session",
(d) => ({ (d) => ({
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text id: d
.text()
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
userId: d userId: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
@@ -86,7 +141,11 @@ export const sessions = createTable(
ipAddress: d.text(), // Matched DB: text ipAddress: d.text(), // Matched DB: text
userAgent: d.text(), // Matched DB: text userAgent: d.text(), // Matched DB: text
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()), updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
}), }),
(t) => [index("session_userId_idx").on(t.userId)], (t) => [index("session_userId_idx").on(t.userId)],
); );
@@ -98,12 +157,20 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({
export const verificationTokens = createTable( export const verificationTokens = createTable(
"verification_token", "verification_token",
(d) => ({ (d) => ({
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text id: d
.text()
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
identifier: d.varchar({ length: 255 }).notNull(), identifier: d.varchar({ length: 255 }).notNull(),
value: d.varchar({ length: 255 }).notNull(), value: d.varchar({ length: 255 }).notNull(),
expiresAt: d.timestamp().notNull(), expiresAt: d.timestamp().notNull(),
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()), updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
}), }),
(t) => [index("verification_token_identifier_idx").on(t.identifier)], (t) => [index("verification_token_identifier_idx").on(t.identifier)],
); );
@@ -111,14 +178,25 @@ export const verificationTokens = createTable(
export const ssoProviders = createTable( export const ssoProviders = createTable(
"sso_provider", "sso_provider",
(d) => ({ (d) => ({
id: d.varchar({ length: 255 }).notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), id: d
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
providerId: d.varchar({ length: 255 }).notNull().unique(), providerId: d.varchar({ length: 255 }).notNull().unique(),
userId: d.varchar({ length: 255 }).notNull().references(() => users.id), userId: d
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields
oidcConfig: d.text(), oidcConfig: d.text(),
samlConfig: d.text(), samlConfig: d.text(),
createdAt: d.timestamp().notNull().defaultNow(), createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()), updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
}), }),
(t) => [index("sso_provider_user_id_idx").on(t.userId)], (t) => [index("sso_provider_user_id_idx").on(t.userId)],
); );
@@ -230,6 +308,7 @@ export const invoices = createTable(
.primaryKey() .primaryKey()
.$defaultFn(() => crypto.randomUUID()), .$defaultFn(() => crypto.randomUUID()),
invoiceNumber: d.varchar({ length: 100 }).notNull(), invoiceNumber: d.varchar({ length: 100 }).notNull(),
invoicePrefix: d.varchar({ length: 20 }).default("#"),
businessId: d.varchar({ length: 255 }).references(() => businesses.id), businessId: d.varchar({ length: 255 }).references(() => businesses.id),
clientId: d clientId: d
.varchar({ length: 255 }) .varchar({ length: 255 })
@@ -241,6 +320,7 @@ export const invoices = createTable(
totalAmount: d.real().notNull().default(0), totalAmount: d.real().notNull().default(0),
taxRate: d.real().notNull().default(0.0), taxRate: d.real().notNull().default(0.0),
notes: d.varchar({ length: 1000 }), notes: d.varchar({ length: 1000 }),
emailMessage: d.varchar({ length: 2000 }),
currency: d.varchar({ length: 3 }).default("USD").notNull(), currency: d.varchar({ length: 3 }).default("USD").notNull(),
createdById: d createdById: d
.varchar({ length: 255 }) .varchar({ length: 255 })
@@ -334,6 +414,7 @@ export const expenses = createTable(
category: d.varchar({ length: 100 }), category: d.varchar({ length: 100 }),
billable: d.boolean().default(false).notNull(), billable: d.boolean().default(false).notNull(),
reimbursable: d.boolean().default(false).notNull(), reimbursable: d.boolean().default(false).notNull(),
taxDeductible: d.boolean().default(false).notNull(),
notes: d.varchar({ length: 500 }), notes: d.varchar({ length: 500 }),
createdById: d createdById: d
.varchar({ length: 255 }) .varchar({ length: 255 })
@@ -410,4 +491,3 @@ export const invoiceTemplatesRelations = relations(
}), }),
}), }),
); );
+311 -5
View File
@@ -34,8 +34,155 @@
/* 16px Global Radius */ /* 16px Global Radius */
} }
:root[data-interface-theme="shadcn"] {
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--radius: 0.5rem;
}
:root[data-interface-theme="beenvoice"] {
--secondary: 240 4.8% 90%;
--secondary-foreground: 240 5.9% 10%;
--radius: 1rem;
}
:root[data-interface-theme="minimal"] {
--background: 0 0% 100%;
--card: 0 0% 100%;
--popover: 0 0% 100%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 96.5%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 97%;
--accent: 240 4.8% 96%;
--accent-foreground: 240 5.9% 10%;
}
:root[data-interface-theme="editorial"] {
--background: 36 33% 98%;
--card: 36 33% 99%;
--popover: 36 33% 99%;
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 30 18% 91%;
--secondary-foreground: 24 10% 10%;
--muted: 30 20% 94%;
--accent: 346.8 77.2% 49.8%;
--accent-foreground: 355.7 100% 97.3%;
--border: 30 15% 86%;
--input: 30 15% 86%;
}
:root[data-body-font="brand"],
:root[data-body-font="inter"] {
--app-font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}
:root[data-body-font="platform"] {
--app-font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-body-font="serif"] {
--app-font-sans:
ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
}
:root[data-heading-font="brand"],
:root[data-heading-font="serif"] {
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-heading-font="platform"] {
--app-font-heading:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-heading-font="inter"] {
--app-font-heading: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="brand"]:not([data-body-font]) {
--app-font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-font="platform"]:not([data-body-font]) {
--app-font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
--app-font-heading:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-font="inter"]:not([data-body-font]) {
--app-font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="serif"]:not([data-body-font]) {
--app-font-sans:
ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-radius="none"] {
--radius: 0rem;
}
:root[data-radius="sm"] {
--radius: 0.25rem;
}
:root[data-radius="md"] {
--radius: 0.5rem;
}
:root[data-radius="lg"] {
--radius: 0.75rem;
}
:root[data-radius="xl"] {
--radius: 1rem;
}
:root[data-color-mode="dark"],
:root.dark {
--background: 240 10% 3.9%;
/* #09090B */
--foreground: 0 0% 98%;
/* #FAFAFA */
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 20%;
/* #27272A */
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
/* #27272A */
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root:not([data-color-mode="light"]) {
--background: 240 10% 3.9%; --background: 240 10% 3.9%;
/* #09090B */ /* #09090B */
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
@@ -61,6 +208,65 @@
--ring: 240 4.9% 83.9%; --ring: 240 4.9% 83.9%;
} }
} }
:root[data-color-theme="slate"] {
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
}
:root[data-color-theme="blue"] {
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--accent: 217.2 91.2% 59.8%;
--accent-foreground: 210 40% 98%;
}
:root[data-color-theme="green"] {
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--accent: 142.1 70.6% 45.3%;
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-color-theme="rose"] {
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--accent: 346.8 77.2% 49.8%;
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-color-theme="orange"] {
--primary: 24.6 95% 53.1%;
--primary-foreground: 60 9.1% 97.8%;
--accent: 20.5 90.2% 48.2%;
--accent-foreground: 60 9.1% 97.8%;
}
:root[data-color-theme="custom"] {
--primary: var(--custom-primary, 142.1 76.2% 36.3%);
--primary-foreground: 355.7 100% 97.3%;
--accent: var(--custom-primary, 142.1 76.2% 36.3%);
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-color-mode="dark"][data-color-theme="slate"],
:root.dark[data-color-theme="slate"] {
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
}
@media (prefers-color-scheme: dark) {
:root:not([data-color-mode="light"])[data-color-theme="slate"] {
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
}
}
} }
@theme inline { @theme inline {
@@ -84,9 +290,9 @@
--color-input: hsl(var(--input)); --color-input: hsl(var(--input));
--color-ring: hsl(var(--ring)); --color-ring: hsl(var(--ring));
--font-sans: var(--font-sans), sans-serif; --font-sans: var(--app-font-sans), ui-sans-serif, system-ui, sans-serif;
--font-heading: var(--font-heading), serif; --font-heading: var(--app-font-heading), ui-serif, Georgia, serif;
--font-mono: var(--font-geist-mono), monospace; --font-mono: var(--font-geist-mono), ui-monospace, monospace;
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
@@ -114,6 +320,87 @@
} }
@layer utilities { @layer utilities {
:root[data-interface-theme="shadcn"] .brand-background,
:root[data-interface-theme="minimal"] .brand-background {
display: none;
}
:root[data-interface-theme="minimal"] [data-slot="card"] {
background-color: transparent;
border-color: transparent;
border-radius: 0;
border-top-color: hsl(var(--border));
box-shadow: none;
backdrop-filter: none;
overflow: visible;
}
:root[data-interface-theme="minimal"] [data-slot="card"] + [data-slot="card"],
:root[data-interface-theme="minimal"] .form-section + .form-section {
border-top: 1px solid hsl(var(--border));
padding-top: 1rem;
}
:root[data-interface-theme="minimal"] [data-slot="card-header"],
:root[data-interface-theme="minimal"] [data-slot="card-content"],
:root[data-interface-theme="minimal"] [data-slot="card-footer"] {
padding-inline: 0;
}
:root[data-interface-theme="minimal"] [data-slot="card-header"] {
padding-top: 0.75rem;
padding-bottom: 0.5rem;
}
:root[data-interface-theme="minimal"] [data-slot="card-content"] {
padding-bottom: 0.75rem;
}
:root[data-interface-theme="minimal"] .page-enter,
:root[data-interface-theme="minimal"] [class*="space-y-8"],
:root[data-interface-theme="minimal"] [class*="space-y-6"] {
row-gap: 1rem;
}
:root[data-interface-theme="minimal"]
[class*="space-y-8"]
> :not([hidden])
~ :not([hidden]),
:root[data-interface-theme="minimal"]
[class*="space-y-6"]
> :not([hidden])
~ :not([hidden]) {
margin-top: 1rem;
}
:root[data-interface-theme="minimal"] [class*="gap-6"] {
gap: 1rem;
}
:root[data-interface-theme="minimal"] .platform-header-surface {
background-color: transparent;
border-color: transparent;
box-shadow: none;
backdrop-filter: none;
overflow: visible;
}
:root[data-interface-theme="minimal"] .platform-header-content {
padding: 0;
}
:root[data-interface-theme="minimal"] .platform-header-gradient {
display: none;
}
:root[data-interface-theme="minimal"] .bg-dashboard {
background-color: hsl(var(--background));
}
:root[data-interface-theme="editorial"] .brand-background {
opacity: 0.55;
}
.animate-blob { .animate-blob {
animation: blob 7s infinite; animation: blob 7s infinite;
} }
@@ -135,6 +422,25 @@
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px -4px hsl(var(--foreground) / 0.1); box-shadow: 0 4px 12px -4px hsl(var(--foreground) / 0.1);
} }
:root[data-radius] .rounded-sm {
border-radius: var(--radius-sm);
}
:root[data-radius] .rounded,
:root[data-radius] .rounded-md {
border-radius: var(--radius-md);
}
:root[data-radius] .rounded-lg {
border-radius: var(--radius-lg);
}
:root[data-radius] .rounded-xl,
:root[data-radius] .rounded-2xl,
:root[data-radius] .rounded-3xl {
border-radius: var(--radius-xl);
}
} }
@keyframes blob { @keyframes blob {
@@ -153,4 +459,4 @@
100% { 100% {
transform: translate(0px, 0px) scale(1); transform: translate(0px, 0px) scale(1);
} }
} }
+1 -1
View File
@@ -7,8 +7,8 @@ import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import { useState } from "react"; import { useState } from "react";
import SuperJSON from "superjson"; import SuperJSON from "superjson";
import { type AppRouter } from "~/server/api/root";
import { createQueryClient } from "./query-client"; import { createQueryClient } from "./query-client";
import type { AppRouter } from "~/server/api/root";
let clientQueryClientSingleton: QueryClient | undefined = undefined; let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => { const getQueryClient = () => {
+1
View File
@@ -12,6 +12,7 @@ export interface Invoice {
totalAmount: number; totalAmount: number;
taxRate: number; taxRate: number;
notes: string | null; notes: string | null;
emailMessage: string | null;
createdById: string; createdById: string;
createdAt: Date; createdAt: Date;
updatedAt: Date | null; updatedAt: Date | null;
-123
View File
@@ -1,123 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[start.sh] Starting beenvoice in production mode"
# Detect if running inside a Docker container
IS_DOCKER=false
if [ -f /\.dockerenv ]; then
IS_DOCKER=true
fi
if [ "$IS_DOCKER" = false ]; then
## Host mode: prepare env, then run containers
if [ ! -f ./.env ] && { [ -f ./.env.example ] || [ -f ./env.example ]; }; then
echo "[start.sh] No .env detected. Creating from env.example with generated secrets..."
GEN_AUTH_SECRET=$(openssl rand -hex 32 || cat /proc/sys/kernel/random/uuid)
GEN_DB_PASSWORD=$(openssl rand -hex 16 || cat /proc/sys/kernel/random/uuid)
tmp_env=$(mktemp)
ENV_TEMPLATE="./.env.example"
if [ -f ./env.example ]; then ENV_TEMPLATE="./env.example"; fi
sed \
-e "s/^AUTH_SECRET=__GENERATE__/AUTH_SECRET=${GEN_AUTH_SECRET}/" \
-e "s/^POSTGRES_PASSWORD=__GENERATE__/POSTGRES_PASSWORD=${GEN_DB_PASSWORD}/" \
"$ENV_TEMPLATE" > "$tmp_env"
mv "$tmp_env" ./.env
echo "[start.sh] Created .env. Please review and edit it as needed, then run this script again."
exit 1
fi
# Auto-generate missing placeholders in existing .env
if [ -f ./.env ]; then
set -a; . ./.env; set +a
updated_env=false
if [ -z "${AUTH_SECRET:-}" ] || grep -qE '^AUTH_SECRET=($|__GENERATE__)' ./.env; then
new_auth_secret=$(openssl rand -hex 32 || cat /proc/sys/kernel/random/uuid)
sed -i.bak -e "s/^AUTH_SECRET=.*/AUTH_SECRET=${new_auth_secret}/" ./.env || echo "AUTH_SECRET=${new_auth_secret}" >> ./.env
updated_env=true
fi
if [ -z "${POSTGRES_PASSWORD:-}" ] || grep -qE '^POSTGRES_PASSWORD=($|__GENERATE__)' ./.env; then
new_db_pw=$(openssl rand -hex 16 || cat /proc/sys/kernel/random/uuid)
sed -i.bak -e "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=${new_db_pw}/" ./.env || echo "POSTGRES_PASSWORD=${new_db_pw}" >> ./.env
updated_env=true
fi
if [ "$updated_env" = true ]; then rm -f ./.env.bak || true; fi
fi
# Ensure docker is available
if ! command -v docker >/dev/null 2>&1; then
echo "[start.sh] ERROR: docker is not installed or not in PATH." >&2
exit 1
fi
echo "[start.sh] Bringing up containers with docker compose..."
docker compose up -d
echo "[start.sh] Containers started. View logs with: docker compose logs -f app"
exit 0
fi
# Container mode: continue to runtime checks and start app
# If .env exists but secrets are missing or placeholders, auto-generate and update file
updated_env=false
if [ -f ./.env ]; then
if [ -z "${AUTH_SECRET:-}" ] || grep -qE '^AUTH_SECRET=($|__GENERATE__)' ./.env; then
new_auth_secret=$(openssl rand -hex 32 || cat /proc/sys/kernel/random/uuid)
sed -i.bak -e "s/^AUTH_SECRET=.*/AUTH_SECRET=${new_auth_secret}/" ./.env || echo "AUTH_SECRET=${new_auth_secret}" >> ./.env
AUTH_SECRET=${new_auth_secret}
updated_env=true
fi
if [ -z "${POSTGRES_PASSWORD:-}" ] || grep -qE '^POSTGRES_PASSWORD=($|__GENERATE__)' ./.env; then
new_db_pw=$(openssl rand -hex 16 || cat /proc/sys/kernel/random/uuid)
sed -i.bak -e "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=${new_db_pw}/" ./.env || echo "POSTGRES_PASSWORD=${new_db_pw}" >> ./.env
POSTGRES_PASSWORD=${new_db_pw}
updated_env=true
fi
# Compose DATABASE_URL if missing but POSTGRES_* present
if [ -z "${DATABASE_URL:-}" ] && [ -n "${POSTGRES_USER:-}" ] && [ -n "${POSTGRES_PASSWORD:-}" ] && [ -n "${POSTGRES_DB:-}" ]; then
DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}"
echo "DATABASE_URL=${DATABASE_URL}" >> ./.env
updated_env=true
fi
# Reload env if we updated it
if [ "$updated_env" = true ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
rm -f ./.env.bak || true
fi
fi
# Ensure required env vars are present (fail fast for critical ones)
if [ -z "${DATABASE_URL:-}" ]; then
echo "[start.sh] ERROR: DATABASE_URL must be set (in .env or environment)." >&2
exit 1
fi
if [ -z "${AUTH_SECRET:-}" ]; then
echo "[start.sh] ERROR: AUTH_SECRET must be set (in .env or environment)." >&2
exit 1
fi
if [ -z "${RESEND_API_KEY:-}" ]; then
echo "[start.sh] ERROR: RESEND_API_KEY must be set (in .env or environment)." >&2
exit 1
fi
# Optional: allow skipping migrations with SKIP_DB_MIGRATION=true
SKIP_DB_MIGRATION=${SKIP_DB_MIGRATION:-false}
if [ "$SKIP_DB_MIGRATION" != "true" ]; then
echo "[start.sh] Applying database migrations"
SKIP_ENV_VALIDATION=1 bun src/server/db/migrate.ts
else
echo "[start.sh] Skipping DB migration due to SKIP_DB_MIGRATION=${SKIP_DB_MIGRATION}"
fi
PORT=${PORT:-3000}
HOSTNAME_ENV=${HOSTNAME:-0.0.0.0}
echo "[start.sh] Starting Next.js server on ${HOSTNAME_ENV}:${PORT}"
exec bun run start -p "${PORT}" -H "${HOSTNAME_ENV}"