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

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

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

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

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

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

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

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

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

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 01:53:15 +00:00
soconnor fb5ffc3195 upd: upgrade dependencies and improve invoice form layout 2026-04-04 21:09:24 -04:00
soconnor 1b6dfbb460 Fix invoice edit cache invalidation issue
- Add cache invalidation after invoice create/update mutations
- Properly invalidate both getById and getAll queries
- Prevents stale data from being displayed after saving
- Fixes flaky behavior where updates didn't appear immediately
2026-01-14 13:21:49 -05:00
soconnor 01f3b408e9 upd: change plugin for oidc 2026-01-14 03:30:15 -05:00
soconnor ea9dc35323 db: push sso changes 2026-01-14 03:24:30 -05:00
soconnor 1cf3dc4d6f feat: manual account linking 2026-01-14 03:20:31 -05:00
soconnor 0696e488e6 feat: add account linking 2026-01-14 03:17:54 -05:00
soconnor 0d5aae3f1b fix: adding explicit JWKS URI to bypass discovery issues 2026-01-14 03:14:55 -05:00
soconnor ee98bc6fcb fix: redundancy for redirect 2026-01-14 03:10:10 -05:00
soconnor 9aa0179d2e fix: change domain 2026-01-14 03:05:27 -05:00
soconnor cba39f80dc fix: testing 2026-01-14 03:04:04 -05:00
soconnor c8ac5710cf fix: add trusted origins 2026-01-14 02:59:52 -05:00
soconnor b90eb6d426 fix: add issuer env 2026-01-14 02:50:57 -05:00
soconnor 07d1dd6fc3 fix: remove proxy 2026-01-14 02:47:42 -05:00
soconnor d5f337df80 fix: hide mock in prod 2026-01-14 02:36:27 -05:00
soconnor d4df1a5104 lib: update next 2026-01-14 02:34:55 -05:00
soconnor 302f3cb3f5 feat: add oidc support with authentik 2026-01-14 02:33:20 -05:00
soconnor 180f14dfb0 feat: improve invoice calendar item display and date picker icon button styling 2025-12-14 22:02:04 -05:00
soconnor 32cffa34fa feat: Enhance DatePicker and NumberInput components, refactor invoice line item UI, sort invoice items by date, and remove Vercel configuration. 2025-12-14 21:13:18 -05:00
soconnor ed0dacb435 feat: Implement a new CountUp component and refactor calendar day details to use a Sheet instead of a Dialog. 2025-12-14 02:16:29 -05:00
soconnor 91d410cbce refactor: Remove env.example, optimize invoice calendar item selection with derived state, and enhance invoice form's default hourly rate initialization and save button loading state. 2025-12-14 02:16:21 -05:00
soconnor 75c4362d97 fix: resolve all remaining type safety errors
- Create types.ts with proper TypeScript interfaces for dashboard data
- Replace all 'any' types in dashboard/page.tsx with DashboardStats and RecentInvoice
- Fix type safety in invoice-form.tsx:
  - Replace 'any' in updateItem with proper union type
  - Add generic type parameter to updateField for type safety
  - Fix status type assertion (any -> proper union type)
  - Replace || with ?? for safer null handling
- All TypeScript compilation errors resolved
- Lint down to 1 warning (false positive for 'loading' variable)
2025-12-11 20:15:29 -05:00
soconnor cf4ef928b8 fix: resolve majority of lint errors across codebase
- Remove unused imports from page.tsx, clients/page.tsx, invoices/page.tsx
- Remove unused imports from invoice-form.tsx, invoice-workspace.tsx
- Move CustomTooltip outside component in revenue-chart.tsx (fixes react-hooks/static-components)
- Fix type safety in umami.ts (any -> unknown)
- Fix type safety in sidebar-provider.tsx (add type assertion)
- Add no-op comments to empty fallback functions in animation-preferences-provider.tsx
- Fix type safety in invoice-workspace.tsx (any[] -> typed array)

Note: dashboard/page.tsx still has ~55 type safety warnings related to 'any' types
in stats/invoice data. These are pre-existing and would require significant refactoring
of the dashboard data flow to properly type. TypeScript compilation passes.
2025-12-11 20:05:34 -05:00
soconnor 50735b74ea fix: resolve lint errors in modified files
- Remove unused imports from invoice-form.tsx, sidebar.tsx, dashboard-shell.tsx
- Remove unused imports from dashboard.ts and calendar.tsx
- Fix unused parameter in invoice-calendar-view.tsx with underscore prefix
- Fix type-only import for DayButton in calendar.tsx
- All typecheck errors resolved
- Remaining lint errors are in unmodified files (pre-existing)
2025-12-11 20:01:04 -05:00
soconnor 1a3c2e08ce refactor: improve invoice editor UX and fix visual issues
- Remove clock icons and hour text from calendar month view, show only activity bars
- Fix calendar week view mobile layout (2-column grid instead of vertical stack)
- Update invoice form skeleton to match actual layout structure
- Add client-side validation for empty invoice item descriptions with auto-scroll to error
- Fix hourly rate defaulting logic with proper type guards
- Update invoice details skeleton to match page structure with PageHeader
- Fix hydration error in sidebar (div inside button -> span)
- Improve dashboard chart color consistency (draft status now matches monthly metrics)
- Fix mobile header layout to prevent text squishing (vertical stack on mobile)
- Add IDs to invoice line items for scroll-into-view functionality
2025-12-11 19:57:54 -05:00
soconnor 39fdf16280 feat: Implement a new dashboard shell with animated background, refactor dashboard data fetching into a dedicated API route, and introduce new UI components.** 2025-12-10 03:16:36 -05:00
soconnor ca6484aea5 hotfix: update next/react to patch RCE exploit 2025-12-03 20:44:07 -05:00
soconnor 77498967ec chore: update Next.js to 16.0.6 and refresh bun lockfile 2025-11-30 19:33:03 -05:00
soconnor 10d7500ef3 feat: integrate Umami analytics for client-side and server-side event tracking 2025-11-30 19:28:25 -05:00
soconnor e27877c477 fix: Ensure color theme syncs correctly by updating useEffect dependencies, refactor email base URL assignment, and add baseline-browser-mapping dependency. 2025-11-29 03:16:46 -05:00
soconnor 03579bc625 feat: Implement database persistence and synchronization for user theme preferences 2025-11-29 03:08:10 -05:00
soconnor a1c7b9223f feat: Remove Vercel Analytics, generalize deployment instructions, and switch base URL configuration to NEXT_PUBLIC_APP_URL. 2025-11-29 02:52:10 -05:00
soconnor 2fc03566d1 feat: introduce BETTER_AUTH_URL and NEXT_PUBLIC_APP_URL environment variables and update password reset link. 2025-11-29 02:47:30 -05:00
soconnor 079d9b6282 chore: Set database connection SSL configuration to false. 2025-11-29 02:39:09 -05:00
soconnor 5723ca07a8 feat: add trusted origin to authentication configuration. 2025-11-29 02:32:58 -05:00
soconnor a452526cbb refactor: Switch clone-local.sh to use local pg_dump/psql and remove an old Drizzle migration file. 2025-11-29 02:29:30 -05:00
soconnor 3ebec7aa4a refactor: migrate authentication system and update Drizzle schema. 2025-11-29 02:26:26 -05:00
soconnor c88e5d9d82 feat: Implement dynamic accent color selection and refactor appearance settings 2025-11-29 00:49:24 -05:00
soconnor 10e1ca8396 feat: Add comprehensive theme management with mode and color selectors, alongside new fonts. 2025-11-27 23:31:10 -05:00
soconnor 0809f75673 Disable ESLint during Next.js builds 2025-11-25 02:05:00 -05:00
soconnor 35ca35c28a Clean up unused imports and refactor type definitions
- Remove unused `cn` import from theme-selector - Remove unused `Slot`
import from badge - Remove unused `X` icon import from switch - Replace
empty interface extends with type alias in input - Replace empty
interface extends with type alias in textarea - Add "secondary" variant
to button type props - Replace "brand" variant with "default" in
client-list and invoice cards
2025-11-25 02:01:16 -05:00
soconnor 75ce36cf9c Update Next.js to v15.5.6 and upgrade dependencies
Bump Next.js from 15.4.5 to 15.5.6 and update related dependencies.

Also upgrade other packages to latest compatible versions including: -
Radix UI components (all minor version updates) - Tiptap editor (3.0.7 →
3.11.0) - React and React DOM (19.1.1 → 19.2.0) - TanStack Query (5.84.0
→ 5.90.10) - TypeScript and ESLint ecosystem - Tailwind CSS (4.1.11 →
4.1.17) - Various other patch and minor updates

Additionally add theme support with next-themes and multiple color
schemes (light, dark, sunset, forest).
2025-11-25 01:54:23 -05:00
soconnor a69b8f029b Improve PDF export error handling and logging
Add detailed logging for file-saver failures and fallback download
attempts. Wrap fallback logic in try-catch to handle edge cases where
manual download also fails, providing better debugging and user-facing
error messages.
2025-11-25 00:37:49 -05:00
soconnor fd6b490de1 Remove debug logging and simplify error handling 2025-11-24 21:47:47 -05:00
soconnor 843f9ceed0 Add fallback download method with MIME type handling
Implement a downloadBlob utility function that provides browser
compatibility by falling back to manual blob download when file-saver
fails. Explicitly set MIME type to application/pdf and add debugging
logging throughout the PDF generation and download process.
2025-11-24 21:47:04 -05:00
soconnor 543c553786 Update Next.js to version 15.4.2 and refactor invoice pages
- Upgrade Next.js and related packages for improved performance and security
- Refactor invoice-related pages to streamline navigation and enhance user experience
- Consolidate invoice editing and viewing functionality into a single page
- Remove deprecated edit page and implement a new view page for invoices
- Update links and routing for consistency across the dashboard
2025-08-11 22:37:40 -04:00
soconnor a270f6c1e5 Add user-controlled animation preferences and reduce motion support
- Persist prefersReducedMotion and animationSpeedMultiplier in user
profile - Provide UI controls to toggle reduce motion and adjust
animation speed globally - Centralize animation preferences via provider
and useAnimationPreferences hook - Apply preferences to charts’
animations (duration, enabled/disabled) - Inline script in layout to
apply preferences early and avoid FOUC - Update CSS to respect user
preference with reduced motion overrides and variable animation speeds
2025-08-11 17:54:53 -04:00
soconnor 46767ca7e2 Improve input validation and data sanitization
The changes add consistent string trimming, better null handling, and
improved validation logic across the business and client forms.
2025-08-11 02:48:24 -04:00
soconnor a680f89a46 Add business nickname support across app and API 2025-08-11 01:50:20 -04:00
soconnor 93ffdf3c86 Add global animation system and entrance effects to UI 2025-08-01 14:21:10 -04:00
soconnor eaf185d89e Responsive flow for mobile updates 2025-08-01 13:51:41 -04:00
soconnor 4fbb12643c Disable a11y alt-text rule for footer logo image 2025-08-01 03:49:35 -04:00
soconnor bb99809b4f Fix PDF pagination and logo rendering issues
Keep content from breaking across pages and adjust logo display in the
invoice footer. Make height calculations more conservative to avoid
content overlapping.
2025-08-01 03:48:02 -04:00
soconnor 4f249fc777 Refactor data export logic and fix whitespace in styles
The message body wasn't needed since the subject line adequately
describes the changes: refactoring the data export handling into a
separate callback function and fixing extra whitespace in CSS class
names.
2025-08-01 03:42:32 -04:00
soconnor f87cc2f295 Update defaultHourlyRate dependency array in InvoiceForm 2025-08-01 03:39:23 -04:00
soconnor 9de86df070 Fix edit invoice initialization and routing
The form initialization logic for editing invoices was improved to
handle route changes correctly. The edit link path was fixed and cache
invalidation was added to ensure fresh data on navigation.
2025-08-01 03:33:19 -04:00
soconnor 5e30d338af Prevent defaultHourlyRate from overwriting edited first item 2025-08-01 00:31:39 -04:00
soconnor e8fb8fa21c Add eslint-disable for useEffect dependency warning 2025-08-01 00:22:59 -04:00
soconnor e53d5944d0 Graph styling 2025-08-01 00:18:11 -04:00
soconnor 22bbe3a1ed Remove unused pool timeout configuration options 2025-07-31 23:13:49 -04:00
soconnor 43b8fd6c9e Use theme-aware chart colors and update color variables 2025-07-31 23:10:55 -04:00
soconnor 8c8f09dab9 Update invoice-form.tsx 2025-07-31 19:14:11 -04:00
soconnor 2eac74ea0c Default hourly 2025-07-31 19:14:01 -04:00
soconnor d9515f7723 Make hourly rate optional for clients and invoices 2025-07-31 19:11:20 -04:00
soconnor 817689001c New invoice bug fix 2025-07-31 18:54:24 -04:00
soconnor cd062d6670 Update pdf-export.tsx 2025-07-31 18:48:25 -04:00
soconnor 860693edcd Update favicon.ico 2025-07-31 18:41:10 -04:00
soconnor 2a4f78a762 Theme overhaul - missing files 2025-07-31 18:37:45 -04:00
soconnor 8a2565adad Theme overhaul 2025-07-31 18:37:33 -04:00
soconnor a1616b161d Add flashy UI animations and enhance PDF invoice layout
- Adds CSS animations for buttons, cards, icons, and text - Improves
homepage with animated elements and interactive effects - Refines PDF
export: better notes/totals layout, colors, and spacing - Updates styles
for more engaging user experience
2025-07-30 21:28:59 -04:00
soconnor 0040fae499 Use transition-colors for brand buttons
Adjust PDF export pagination for better orphan handling

Increase dense header space to 300px for PDF export

Prevent orphan pages with fewer than 2 items in PDF export
2025-07-30 20:39:06 -04:00
soconnor acc8731e09 Add confirmation dialog before sending invoice email
The commit adds a confirmation dialog when sending invoices, improves
error handling with retries, and refines email-related UI text.
2025-07-29 20:15:40 -04:00
soconnor 8cd9035f3c Add Vercel Analytics and improve PDF export layout
The main changes are: - Add Vercel Analytics to track site usage -
Improve PDF invoice layout and pagination: - Better line height and
padding for description text - Dynamic row height based on content
length - More accurate pagination calculations - Prevent orphaned items
on last page - Clean up formatting and spacing - Remove database backup
and Docker files
2025-07-29 19:48:57 -04:00
soconnor 9370d5c935 Build fixes, email preview system 2025-07-29 19:45:38 -04:00
soconnor e6791f8cb8 Update README.md 2025-07-29 19:11:34 -04:00
soconnor 51872a3277 Convert invoice view to client component
This conversion enables client-side features like delete functionality
with confirmation dialog and live data updates through React Query
2025-07-20 03:57:33 -04:00
soconnor d5f9d1f583 Add invoice deletion functionality
The changes implement deletion capabilities for invoices with proper UI
feedback and confirmation dialogs.
2025-07-20 03:51:34 -04:00
soconnor 3ac6e4d5b8 Update Next.js to version 15.4.2 and refactor invoice pages
- Upgrade Next.js and related packages for improved performance and security
- Refactor invoice-related pages to streamline navigation and enhance user experience
- Consolidate invoice editing and viewing functionality into a single page
- Remove deprecated edit page and implement a new view page for invoices
- Update links and routing for consistency across the dashboard
2025-07-18 20:18:43 -04:00
219 changed files with 23756 additions and 18974 deletions
+17
View File
@@ -0,0 +1,17 @@
node_modules
.next
.git
.gitignore
Dockerfile*
docker-compose*
README.md
*.log
.DS_Store
.env*
!.env.example
.vscode
.idea
coverage
*.tsbuildinfo
dist
build
+46 -18
View File
@@ -1,23 +1,51 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
# Copy this file to .env before running Docker Compose:
# cp .env.example .env
# This file will be committed to version control, so make sure not to have any
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.
# Runtime
NODE_ENV=production
WEB_PORT=3000
# When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly.
# Auth
# Generate with: openssl rand -base64 32
AUTH_SECRET=change-me-generate-a-real-secret
BETTER_AUTH_URL=http://localhost:3000
# Next Auth
# You can generate a new secret on the command line with:
# npx auth secret
# https://next-auth.js.org/configuration/options#secret
AUTH_SECRET=""
# Public app URL
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Next Auth Discord Provider
AUTH_DISCORD_ID=""
AUTH_DISCORD_SECRET=""
# Postgres used by docker-compose.yml
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=postgres
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
DB_DISABLE_SSL=true
# Drizzle
DATABASE_URL="file:./db.sqlite"
# White-label defaults used at image build time.
# Admin-managed platform branding in the app can override these after setup.
NEXT_PUBLIC_BRAND_NAME="beenvoice"
NEXT_PUBLIC_BRAND_TAGLINE="Simple and efficient invoicing for freelancers and small businesses"
NEXT_PUBLIC_BRAND_LOGO_TEXT="beenvoice"
NEXT_PUBLIC_BRAND_ICON="$"
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME="beenvoice"
NEXT_PUBLIC_DEFAULT_FONT="brand"
NEXT_PUBLIC_DEFAULT_BODY_FONT="brand"
NEXT_PUBLIC_DEFAULT_HEADING_FONT="brand"
NEXT_PUBLIC_DEFAULT_RADIUS="xl"
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE="floating"
# Email delivery via Resend (optional)
# Leave blank to disable invoice/password-reset email delivery.
RESEND_API_KEY=
RESEND_DOMAIN=
# Analytics via Umami (optional)
# Leave website ID blank to disable analytics.
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
# SSO via Authentik OIDC (optional)
NEXT_PUBLIC_AUTHENTIK_ENABLED=false
AUTHENTIK_ISSUER=
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
AUTHENTIK_ORIGIN=
+2 -3
View File
@@ -34,10 +34,9 @@ yarn-error.log*
# 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
.env
.env.prod
.env*.local
# vercel
.vercel
.env*.production
# typescript
*.tsbuildinfo
+7
View File
@@ -43,6 +43,13 @@ beenvoice is a professional invoicing application built with the T3 stack (Next.
- Protected routes require authentication
- Follow NextAuth.js security best practices
### Development Tools
- Use ESLint and Prettier for code formatting
- Use TypeScript for type safety
- Exclusively use bun for development and production. Do not use Node.js or Deno.
- Stay away from starting development servers or running builds unless absolutely necessary.
- Run lints and typechecks when helpful.
## Component Architecture
### UI Components (shadcn/ui)
+36
View File
@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1
FROM oven/bun:1 AS base
WORKDIR /usr/src/app
FROM base AS install
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
FROM base AS build
COPY --from=install /usr/src/app/node_modules node_modules
COPY . .
ENV NODE_ENV=production \
SKIP_ENV_VALIDATION=1 \
NODE_OPTIONS=--max-old-space-size=4096 \
BETTER_AUTH_URL=http://localhost:3000 \
AUTH_SECRET=docker-build-placeholder-secret-do-not-use \
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
RUN bun run build && bun build src/server/db/migrate.ts --target=bun --outfile=migrate.js
FROM base AS release
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0
COPY --from=build /usr/src/app/.next/standalone ./
COPY --from=build /usr/src/app/.next/static ./.next/static
COPY --from=build /usr/src/app/public ./public
COPY --from=build /usr/src/app/migrate.js ./migrate.js
COPY --from=build /usr/src/app/drizzle ./drizzle
RUN chmod -R a+rX drizzle migrate.js public
USER bun
EXPOSE 3000
CMD ["sh", "-c", "bun migrate.js && bun server.js"]
+170 -64
View File
@@ -1,3 +1,5 @@
![beenvoice Logo](public/beenvoice-logo.png)
# beenvoice - Invoicing Made Simple
A modern, professional invoicing application built for freelancers and small businesses. beenvoice provides a clean, efficient way to manage clients and create professional invoices with ease.
@@ -6,72 +8,99 @@ A modern, professional invoicing application built for freelancers and small bus
## ✨ Features
- **🔐 Secure Authentication** - Email/password registration and sign-in with NextAuth.js
- **🔐 Secure Authentication** - Email/password registration and sign-in with better-auth, plus SSO via Authentik OIDC
- **👥 Client Management** - Create, edit, and manage client information
- **🏢 Business Profiles** - Manage your business details, logo, and email settings
- **📄 Professional Invoices** - Generate detailed invoices with line items
- **📅 Timesheet View** - Calendar-based time entry with month and week views
- **📧 Email Delivery** - Send invoices via email using Resend
- **📥 PDF Export** - Download invoices as professional PDFs
- **📊 CSV Import** - Bulk import invoice data from CSV files
- **💰 Flexible Pricing** - Set custom rates and calculate totals automatically
- **📱 Responsive Design** - Works seamlessly on desktop, tablet, and mobile
- **🎨 Modern UI** - Clean, professional interface built with shadcn/ui
- **⚡ Type-Safe** - Full TypeScript support with tRPC for API calls
- **💾 Local Database** - SQLite database with Drizzle ORM
- **💾 PostgreSQL Database** - Robust relational database with Drizzle ORM
## 🚀 Tech Stack
- **Frontend**: Next.js 15 with App Router
- **Frontend**: Next.js 16 with App Router
- **Backend**: tRPC for type-safe API calls
- **Database**: Drizzle ORM with LibSQL (SQLite)
- **Authentication**: NextAuth.js with email/password
- **UI Components**: shadcn/ui with Tailwind CSS
- **Styling**: Geist font family
- **Package Manager**: Bun (with npm fallback)
- **Database**: Drizzle ORM with PostgreSQL
- **Authentication**: better-auth with email/password and Authentik OIDC SSO
- **UI Components**: shadcn/ui with Tailwind CSS v4
- **Email**: Resend for transactional email delivery
- **PDF**: @react-pdf/renderer for invoice PDF generation
- **Package Manager**: Bun
## 📦 Installation
### Prerequisites
- Node.js 18+ or Bun
- Docker & Docker Compose (for local PostgreSQL)
- Git
### Quick Start
1. **Clone the repository**
```bash
git clone https://github.com/yourusername/beenvoice.git
cd beenvoice
```
2. **Install dependencies**
```bash
# Using Bun (recommended)
bun install
# Or using npm
npm install
```bash
bun install
```
3. **Set up environment variables**
```bash
cp .env.example .env.local
```
Edit `.env.local` and add your configuration:
```env
DATABASE_URL="file:./db.sqlite"
NEXTAUTH_SECRET="your-secret-key-here"
NEXTAUTH_URL="http://localhost:3000"
# Database
DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice"
DB_DISABLE_SSL="true"
# Authentication
AUTH_SECRET="your-secret-key-here"
BETTER_AUTH_URL="http://localhost:3000"
# Application
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NODE_ENV="development"
# Email (optional for local dev)
RESEND_API_KEY="your-resend-api-key"
RESEND_DOMAIN="yourdomain.com"
```
4. **Initialize the database**
4. **Start the development database**
```bash
docker compose -f docker-compose.dev.yml up -d db
```
5. **Push the database schema**
```bash
bun run db:push
```
5. **Start the development server**
6. **Start the development server**
```bash
bun run dev
```
6. **Open your browser**
7. **Open your browser**
Navigate to [http://localhost:3000](http://localhost:3000)
## 🏗️ Project Structure
@@ -80,21 +109,29 @@ A modern, professional invoicing application built for freelancers and small bus
beenvoice/
├── src/
│ ├── app/ # Next.js App Router pages
│ │ ├── api/ # API routes (NextAuth, tRPC)
│ │ ├── api/ # API routes (better-auth, tRPC)
│ │ ├── auth/ # Authentication pages
│ │ ├── clients/ # Client management pages
│ │ ├── invoices/ # Invoice management pages
│ │ ├── dashboard/ # Main app pages
│ │ │ ├── clients/ # Client management pages
│ │ │ ├── invoices/ # Invoice management pages
│ │ │ └── businesses/ # Business profile pages
│ │ └── _components/ # Page-specific components
│ ├── components/ # Shared UI components
│ │ ├── ui/ # shadcn/ui components
│ │ ├── data/ # Data display components
│ │ ├── forms/ # Form components
│ │ └── layout/ # Layout components
│ ├── server/ # Server-side code
│ │ ├── api/ # tRPC routers
│ │ ├── auth/ # NextAuth configuration
│ │ └── db/ # Database schema and connection
│ ├── lib/ # Utilities (auth, pdf export, etc.)
│ ├── styles/ # Global styles
│ └── trpc/ # tRPC client configuration
├── drizzle/ # Database migrations
├── public/ # Static assets
── docs/ # Documentation
── docs/ # Documentation
├── docker-compose.yml # Deployment compose stack
└── docker-compose.dev.yml # Development overrides with exposed PostgreSQL
```
## 🎯 Usage
@@ -104,41 +141,57 @@ beenvoice/
1. **Register an Account**
- Visit the sign-up page
- Enter your name, email, and password
- Verify your email (if configured)
2. **Add Your First Client**
2. **Set Up Your Business**
- Navigate to Business Settings
- Add your business name, contact info, and logo
- Configure email settings for invoice delivery (Resend API key + domain)
3. **Add Your First Client**
- Navigate to the Clients page
- Click "Add New Client"
- Fill in client details (name, email, phone, address)
3. **Create an Invoice**
4. **Create an Invoice**
- Go to the Invoices page
- Click "Create New Invoice"
- Select a client
- Select a client and optionally a business profile
- Add line items with descriptions, dates, hours, and rates
- Save and generate your invoice
- Use the Timesheet tab for calendar-based time entry
- Save and send or download as PDF
### Features Overview
#### Client Management
- Create and edit client profiles
- Store contact information and addresses
- Set default hourly rates per client
- Search and filter client list
- View client history
#### Invoice Creation
- Select from existing clients
- Add multiple line items
- Select from existing clients and business profiles
- Add multiple line items with drag-and-drop reordering
- Set custom rates per item
- Automatic total calculations
- Automatic total calculations with configurable tax rate
- Timesheet calendar view for date-based time tracking
- Professional invoice formatting
#### Invoice Delivery
- Send invoices via email directly from the app
- Rich text email composer with preview
- Resend and re-deliver sent invoices
- Track invoice status: Draft → Sent → Paid (+ Overdue)
#### User Interface
- Clean, modern design
- Responsive layout
- Intuitive navigation
- Fully responsive — desktop, tablet, and mobile
- Intuitive navigation with breadcrumbs
- Toast notifications for feedback
- Modal dialogs for forms
- Dark mode support
## 🔧 Development
@@ -146,44 +199,72 @@ beenvoice/
```bash
# Development
bun run dev # Start development server
bun run dev # Start development server (Turbo)
bun run build # Build for production
bun run start # Start production server
# Database
bun run db:push # Push schema changes to database
bun run db:migrate # Run migrations
bun run db:studio # Open Drizzle Studio
bun run db:generate # Generate new migration
# Docker
bun run docker:up # Start deployment compose stack
bun run docker:dev:up # Start development compose stack with exposed PostgreSQL
bun run docker:down # Stop Docker services
# Code Quality
bun run lint # Run ESLint
bun run format # Format code with Prettier
bun run type-check # Run TypeScript type checking
bun run lint:fix # Fix ESLint issues
bun run format:write # Format code with Prettier
bun run typecheck # Run TypeScript type checking
```
### 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
The application uses four main tables:
The application uses the following core tables:
- **users**: User accounts and authentication
- **clients**: Client information and contact details
- **invoices**: Invoice headers with client relationships
- **invoice_items**: Individual line items with pricing
- **users** - User accounts and authentication
- **sessions** - Active user sessions
- **clients** - Client information and contact details
- **businesses** - Business profiles with email/logo settings
- **invoices** - Invoice headers with client and business relationships
- **invoice_items** - Individual line items with pricing and position ordering
### API Development
All API endpoints are built with tRPC for type safety:
- **Authentication**: NextAuth.js integration
- **Authentication**: better-auth integration (email/password + OIDC)
- **Clients**: CRUD operations for client management
- **Invoices**: Invoice creation and management
- **Businesses**: Business profile management
- **Invoices**: Invoice creation, management, and status tracking
- **Validation**: Zod schemas for input validation
## 🎨 Customization
### Styling
The app uses Tailwind CSS with a custom design system:
The app uses Tailwind CSS v4 with a custom design system:
- **Primary Color**: Green (#16a34a)
- **Font**: Geist for professional typography
@@ -193,37 +274,63 @@ The app uses Tailwind CSS with a custom design system:
### Branding
Update the logo and colors in:
- `src/components/logo.tsx` - Main logo component
- `src/styles/globals.css` - Color variables
- `src/app/layout.tsx` - Font configuration
## 🚀 Deployment
### Vercel (Recommended)
You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.).
1. Push your code to GitHub
2. Connect your repository to Vercel
3. Set environment variables in Vercel dashboard
4. Deploy automatically on push
1. **Build the application:**
### Other Platforms
```bash
bun run build
```
The app can be deployed to any platform that supports Next.js:
2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production)
- **Netlify**: Use the Next.js build command
- **Railway**: Connect your GitHub repository
- **DigitalOcean App Platform**: Deploy with automatic scaling
3. **Run database migrations:**
```bash
bun run db:push
```
4. **Start the server:**
```bash
bun start
```
### Environment Variables
Required for production:
```env
DATABASE_URL="your-database-url"
NEXTAUTH_SECRET="your-secret-key"
NEXTAUTH_URL="https://your-domain.com"
DATABASE_URL="postgresql://user:password@host:5432/dbname"
AUTH_SECRET="your-long-random-secret"
BETTER_AUTH_URL="https://your-domain.com"
NEXT_PUBLIC_APP_URL="https://your-domain.com"
NODE_ENV="production"
# Email (required for invoice sending)
RESEND_API_KEY="re_xxxxxxxxxxxx"
RESEND_DOMAIN="yourdomain.com"
# Optional: Authentik SSO
AUTHENTIK_ISSUER="https://your-authentik-instance/application/o/beenvoice/"
AUTHENTIK_CLIENT_ID="your-client-id"
AUTHENTIK_CLIENT_SECRET="your-client-secret"
```
### Other Platforms
The app can be deployed to any platform that supports Next.js:
- **Coolify**: Deploy with Docker Compose support
- **Railway**: Connect your GitHub repository (includes managed PostgreSQL)
- **DigitalOcean App Platform**: Deploy with automatic scaling
## 🤝 Contributing
1. Fork the repository
@@ -237,8 +344,7 @@ NEXTAUTH_URL="https://your-domain.com"
- Follow TypeScript best practices
- Use shadcn/ui components for consistency
- Implement proper error handling
- Add tests for new features
- Follow the existing code style
- Follow the existing code style (Prettier + ESLint configs provided)
## 📄 License
@@ -248,14 +354,14 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- [T3 Stack](https://create.t3.gg/) for the excellent development stack
- [shadcn/ui](https://ui.shadcn.com/) for beautiful UI components
- [NextAuth.js](https://next-auth.js.org/) for authentication
- [better-auth](https://www.better-auth.com/) for modern authentication
- [Drizzle ORM](https://orm.drizzle.team/) for database management
- [Resend](https://resend.com/) for reliable email delivery
## 📞 Support
- **Issues**: [GitHub Issues](https://github.com/yourusername/beenvoice/issues)
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/beenvoice/discussions)
- **Email**: support@beenvoice.com
---
+707 -403
View File
File diff suppressed because it is too large Load Diff
-510
View File
@@ -1,510 +0,0 @@
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE `beenvoice_account` (
`userId` text(255) NOT NULL,
`type` text(255) NOT NULL,
`provider` text(255) NOT NULL,
`providerAccountId` text(255) NOT NULL,
`refresh_token` text,
`access_token` text,
`expires_at` integer,
`token_type` text(255),
`scope` text(255),
`id_token` text,
`session_state` text(255),
PRIMARY KEY(`provider`, `providerAccountId`),
FOREIGN KEY (`userId`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
);
CREATE TABLE `beenvoice_invoice_item` (
`id` text(255) PRIMARY KEY NOT NULL,
`invoiceId` text(255) NOT NULL,
`date` integer NOT NULL,
`description` text(500) NOT NULL,
`hours` real NOT NULL,
`rate` real NOT NULL,
`amount` real NOT NULL,
`createdAt` integer DEFAULT (unixepoch()) NOT NULL, `position` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`invoiceId`) REFERENCES `beenvoice_invoice`(`id`) ON UPDATE no action ON DELETE cascade
);
INSERT INTO beenvoice_invoice_item VALUES('9b237b0e-d47e-47d3-9351-777d10c84d38','76d570fe-bfec-47bd-a7fa-b4ee8133c78e',64731153600,'Virtual',1.5,20.0,30.0,1752132158,0);
INSERT INTO beenvoice_invoice_item VALUES('8fb85a95-50f9-4375-86d2-5e0e334d87ce','76d570fe-bfec-47bd-a7fa-b4ee8133c78e',64731326400,'In person',3.0,20.0,60.0,1752132158,0);
INSERT INTO beenvoice_invoice_item VALUES('d9f841ae-4c70-4b3d-ba6a-befec3e07693','76d570fe-bfec-47bd-a7fa-b4ee8133c78e',64732363200,'In person',2.0,20.0,40.0,1752132158,0);
INSERT INTO beenvoice_invoice_item VALUES('fd91ea66-4c98-468d-a1ae-1d6715c028c2','76d570fe-bfec-47bd-a7fa-b4ee8133c78e',64732536000,'In person',4.5,20.0,90.0,1752132158,0);
INSERT INTO beenvoice_invoice_item VALUES('bb1b3ccc-35be-47b9-a328-386d7fdc0260','61c3d28c-5031-4372-86e3-5bf895411046',64733054400,'In person',2.5,20.0,50.0,1752132159,0);
INSERT INTO beenvoice_invoice_item VALUES('33de41fb-3117-4bef-8ced-b9955538f920','61c3d28c-5031-4372-86e3-5bf895411046',64733140800,'In person',5.5,20.0,110.0,1752132159,0);
INSERT INTO beenvoice_invoice_item VALUES('22f8db2c-4d80-4847-8927-7fcce399627e','61c3d28c-5031-4372-86e3-5bf895411046',64733572800,'In person',3.0,20.0,60.0,1752132159,0);
INSERT INTO beenvoice_invoice_item VALUES('52d4126f-3e1b-4f11-a1cd-c14f64ef8785','61c3d28c-5031-4372-86e3-5bf895411046',64733745600,'Race day (flat rate)',1.0,100.0,100.0,1752132159,0);
INSERT INTO beenvoice_invoice_item VALUES('b9588fcb-2081-44f4-a167-2b51567d89a1','57fcd73a-0876-4e91-9856-0f9c9695fcd1',1621051200,'Race Day (fixed)',1.0,100.0,100.0,1752132159,0);
INSERT INTO beenvoice_invoice_item VALUES('fd024a5f-1bf3-4a08-9fb1-fd39502158eb','57fcd73a-0876-4e91-9856-0f9c9695fcd1',1621656000,'Race Day (fixed)',1.0,100.0,100.0,1752132159,0);
INSERT INTO beenvoice_invoice_item VALUES('63e0e171-d1f9-43a7-a465-d883b4996b53','57fcd73a-0876-4e91-9856-0f9c9695fcd1',1622865600,'Race Day (fixed)',1.0,100.0,100.0,1752132159,0);
INSERT INTO beenvoice_invoice_item VALUES('af8b2c9d-147b-49b4-b0a7-0a98ba63abee','555b1f8c-7a57-427f-bea8-ee4a3f2a2bf2',1620619200,'Fix routers',3.0,20.0,60.0,1752132159,0);
INSERT INTO beenvoice_invoice_item VALUES('f9f4712d-9096-4322-978f-3fdff9591939','555b1f8c-7a57-427f-bea8-ee4a3f2a2bf2',1620792000,'Race Day (fixed)',1.0,100.0,100.0,1752132159,0);
INSERT INTO beenvoice_invoice_item VALUES('dc0dc83c-093a-42c1-9c8e-b658f5cac7ef','555b1f8c-7a57-427f-bea8-ee4a3f2a2bf2',1621396800,'Race Day (Fixed)',1.0,100.0,100.0,1752132159,0);
INSERT INTO beenvoice_invoice_item VALUES('c1d379b1-70ea-44c4-a3cd-d4e1f1510722','555b1f8c-7a57-427f-bea8-ee4a3f2a2bf2',1622520000,'RDP Login Configuration',2.5,20.0,50.0,1752132159,0);
INSERT INTO beenvoice_invoice_item VALUES('1212495d-3d81-47ed-ad57-2f938330a95b','555b1f8c-7a57-427f-bea8-ee4a3f2a2bf2',1338696000,'Virtual Database Install/Setup',5.0,20.0,100.0,1752132159,0);
INSERT INTO beenvoice_invoice_item VALUES('dfd60b61-908c-4a8e-b768-c471cbf1699a','555b1f8c-7a57-427f-bea8-ee4a3f2a2bf2',1623297600,'Race Day (fixed)',1.0,100.0,100.0,1752132159,0);
INSERT INTO beenvoice_invoice_item VALUES('c79fc31f-2abb-4b4e-968b-8ced90992bfb','4fb5d8be-2588-4187-955d-e7643b08619f',1627617600,'Office Internet/3Play Configuration',4.0,20.0,80.0,1752132160,0);
INSERT INTO beenvoice_invoice_item VALUES('286c3631-0a36-4177-83e2-e041d3e5e198','4fb5d8be-2588-4187-955d-e7643b08619f',1627704000,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
INSERT INTO beenvoice_invoice_item VALUES('a0599f94-dcbb-4ff7-8f69-f685b200d702','4fb5d8be-2588-4187-955d-e7643b08619f',1628308800,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
INSERT INTO beenvoice_invoice_item VALUES('96f1bee1-1117-4fb8-a40a-4fcd485d6528','f48104da-1baa-4a70-9d0c-c03f4017f60d',1628740800,'Stream Deck/Tower Server',2.5,20.0,50.0,1752132160,0);
INSERT INTO beenvoice_invoice_item VALUES('41fcea00-259c-433c-8744-1da4297ee261','f48104da-1baa-4a70-9d0c-c03f4017f60d',1628913600,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
INSERT INTO beenvoice_invoice_item VALUES('a5a677ea-1c26-4c93-bee5-4e7193d8fc54','f48104da-1baa-4a70-9d0c-c03f4017f60d',1629432000,'Office Server Ransomware/Data Recovery',5.0,20.0,100.0,1752132160,0);
INSERT INTO beenvoice_invoice_item VALUES('fc9e7932-aae0-4611-8dfd-439632e02efe','f48104da-1baa-4a70-9d0c-c03f4017f60d',1629518400,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
INSERT INTO beenvoice_invoice_item VALUES('14d8f2e4-79a1-4f52-80cb-495422c2ff6c','f48104da-1baa-4a70-9d0c-c03f4017f60d',1629864000,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
INSERT INTO beenvoice_invoice_item VALUES('848765c1-2f93-4fe0-bd54-83a8ed6e028b','d0c7c941-d0e4-4d55-b1e5-10ed71f1c9f5',1630728000,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
INSERT INTO beenvoice_invoice_item VALUES('28b59943-9beb-4c64-bf94-f10729ef55e9','d0c7c941-d0e4-4d55-b1e5-10ed71f1c9f5',1631332800,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
INSERT INTO beenvoice_invoice_item VALUES('ef1b5cc8-046e-4720-9126-365bf2011cef','d0c7c941-d0e4-4d55-b1e5-10ed71f1c9f5',1631419200,'Office Server Data Migration (Online)',2.0,20.0,40.0,1752132160,0);
INSERT INTO beenvoice_invoice_item VALUES('07d42569-5d78-4ddc-9146-07c68df081f0','d0c7c941-d0e4-4d55-b1e5-10ed71f1c9f5',1631937600,'Race Day (Fixed)',1.0,100.0,100.0,1752132160,0);
INSERT INTO beenvoice_invoice_item VALUES('f87f4371-4d88-461b-9e20-218841842abd','6c4314c7-7bc7-4d8a-9513-59a1ebcfd890',1635739200,'IT Move server/Vmix/Backups',2.0,20.0,40.0,1752132161,0);
INSERT INTO beenvoice_invoice_item VALUES('fb6b4cf4-b569-42ac-ba14-53e242d07560','6c4314c7-7bc7-4d8a-9513-59a1ebcfd890',1635825600,'Prep In Car Cameras',3.0,20.0,60.0,1752132161,0);
INSERT INTO beenvoice_invoice_item VALUES('f31d6dab-9af3-476c-a272-6e53c3e81a51','6c4314c7-7bc7-4d8a-9513-59a1ebcfd890',1636520400,'Race Day,Islip 300',1.0,100.0,100.0,1752132161,0);
INSERT INTO beenvoice_invoice_item VALUES('0a800d16-bf03-4139-93f6-872a455fbd57','b018eaca-b4b1-4c96-8e40-2a1ab5211e48',1649390400,'Hoosier Tire Scanning',3.0,20.0,60.0,1752132161,0);
INSERT INTO beenvoice_invoice_item VALUES('91c2086d-590a-45ff-8857-006964144c6c','b018eaca-b4b1-4c96-8e40-2a1ab5211e48',1649736000,'SSD Migration/Data Backup',4.0,20.0,80.0,1752132161,0);
INSERT INTO beenvoice_invoice_item VALUES('d705c999-1112-4215-97c8-81888281a27d','b018eaca-b4b1-4c96-8e40-2a1ab5211e48',1650513600,'Roster Numbers/Data Migration',5.5,20.0,110.0,1752132161,0);
INSERT INTO beenvoice_invoice_item VALUES('1cf32daa-b16e-47a9-8d17-3bb65e8bf654','a0da2a05-5681-46fd-b988-235ec24971e2',1651636800,'Laptop setup/Facebook stream',5.0,20.0,100.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('87cee56a-7582-4015-9183-7b917b685b7a','a0da2a05-5681-46fd-b988-235ec24971e2',1652500800,'Race Day',1.0,100.0,100.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('adf5caac-3381-4811-aa9a-fe64c6c0ad20','713a368a-f7de-4de8-95dd-2a4a2d626fa1',1652500800,'Tire Sales (Hoosier)',3.0,20.0,60.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('d278998a-ed4e-47bd-8915-35124d8bc27f','713a368a-f7de-4de8-95dd-2a4a2d626fa1',1652846400,'Raceway CMS Development',6.0,20.0,120.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('694aaa24-b883-4aa1-b365-3e3ded6e9c4f','713a368a-f7de-4de8-95dd-2a4a2d626fa1',1652932800,'Raceway CMS Development',5.0,20.0,100.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('9f5571af-e79e-4254-a370-deb25f16f06c','713a368a-f7de-4de8-95dd-2a4a2d626fa1',1653019200,'Raceway CMS Development',4.0,20.0,80.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('d9f1fcca-a6f1-4f4e-a6ba-52ea102db90a','713a368a-f7de-4de8-95dd-2a4a2d626fa1',1653105600,'Race day (RR)',6.0,20.0,120.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('ebb93ccc-4a9d-4d6f-8584-f044377fdc00','713a368a-f7de-4de8-95dd-2a4a2d626fa1',1653105600,'Tire Sales (Hoosier)',3.0,20.0,60.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('a4f27be7-68ec-492a-b127-21fa207bde52','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1653192000,'Raceway CMS Development',3.0,20.0,60.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('9749ad17-0e9b-4682-8011-aee73425354b','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1653278400,'Raceway CMS Development',3.0,20.0,60.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('29fadf5f-a919-420c-a4a7-778d62b770f9','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1653364800,'Raceway CMS Development',4.0,20.0,80.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('b7f57cc5-ecea-49e3-bb42-15e90dfba1df','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1653537600,'Raceway CMS Development',1.0,20.0,20.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('a8380c9d-0444-4afe-b820-9597a871a903','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1653624000,'Generate points tables on site/tire LAN',4.0,20.0,80.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('fd72e334-4e6a-4462-82a5-cc5a8d3ecda0','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1654056000,'Press Release Publish',1.0,20.0,20.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('7fc11bdd-b740-4c2a-9cf1-2c3bab092f77','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1654315200,'Race Day (RR)',3.0,20.0,60.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('23fbcc77-d0d2-4d0e-90d5-e2f9cab790f7','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1654488000,'PR Archive Integration/Points Update',2.0,20.0,40.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('70be501b-a496-4f40-aebc-a4521fbcf4ba','fac3b7e2-9816-459c-960e-ac520b3f2cd5',1654574400,'Press Release Website Deployment',2.0,20.0,40.0,1752132162,0);
INSERT INTO beenvoice_invoice_item VALUES('9bf35628-6046-44e2-a24f-681ea5bf7bb9','8704d2fe-8972-4dae-8062-2f5b81e14493',1654747200,'Raceway CMS Development',3.0,20.0,60.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('abea397c-2ea5-4788-9560-42ea0d508bce','8704d2fe-8972-4dae-8062-2f5b81e14493',1654833600,'Raceway CMS Development',3.0,20.0,60.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('99590ee9-7e6a-40ec-8925-b135457ba01e','8704d2fe-8972-4dae-8062-2f5b81e14493',1655092800,'TRMM Installation/Script Writing',3.0,20.0,60.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('e1f534be-9fe8-42f1-8ef4-bc4073d8ce2b','8704d2fe-8972-4dae-8062-2f5b81e14493',1655265600,'PC Updates',2.0,20.0,40.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('7e619cdf-af99-4c58-be0e-227324710e4e','8704d2fe-8972-4dae-8062-2f5b81e14493',1655352000,'3Play Remote Access Setup',2.0,20.0,40.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('7fd71054-7626-4f53-94e9-5fc4006ca3c4','8704d2fe-8972-4dae-8062-2f5b81e14493',1655524800,'Race Day',8.0,20.0,160.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('c9d74e45-d270-45b4-9332-25db44c9d6d1','8704d2fe-8972-4dae-8062-2f5b81e14493',1655697600,'Move and reassign printer',3.0,20.0,60.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('ae2ef12b-43b2-454e-90bf-d8b150f89278','8704d2fe-8972-4dae-8062-2f5b81e14493',1655870400,'Website updates/PR Logic',2.0,20.0,40.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('f96d139f-7f40-4a25-89cc-05510c782a7d','8704d2fe-8972-4dae-8062-2f5b81e14493',1656302400,'Website updates',2.0,20.0,40.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('e4c9c5ac-ee0d-490f-9f84-c542ad4b7c5c','8704d2fe-8972-4dae-8062-2f5b81e14493',1656475200,'Website updates/schedule/press-release',2.0,20.0,40.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('fb35940e-4199-4de8-a163-5ffac86ab0c4','babfc847-b37d-44f2-91a9-4251691c11b4',1658376000,'Server updates and TMM',5.0,20.0,100.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('8f4a5ca2-88d9-403e-9098-6b398d4be218','babfc847-b37d-44f2-91a9-4251691c11b4',1658548800,'Race Day (RR)',9.0,20.0,180.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('9107c846-b7b9-4d37-aecf-8b7cbc6cfc70','babfc847-b37d-44f2-91a9-4251691c11b4',1658721600,'CMS Development (remote)',4.0,20.0,80.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('51e881b1-0e7f-4bcd-87da-3512e2345337','babfc847-b37d-44f2-91a9-4251691c11b4',1658808000,'CMS Development (remote)',4.0,20.0,80.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('707b9108-81ea-4af6-aa2f-0de09220a1a8','babfc847-b37d-44f2-91a9-4251691c11b4',1658894400,'CMS Development (remote)',4.0,20.0,80.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('9d4c904c-2421-442f-9b45-09a330de83a4','babfc847-b37d-44f2-91a9-4251691c11b4',1658980800,'CMS Development (in person)',5.0,20.0,100.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('5685ea85-1190-45b5-bc0b-65d3a0ae37f5','babfc847-b37d-44f2-91a9-4251691c11b4',1659153600,'Race Day (Hoosier)',4.0,20.0,80.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('1d14d0de-642f-4266-a466-30ba7773b55f','babfc847-b37d-44f2-91a9-4251691c11b4',1659153600,'Race Day (RR) / Drone photography',6.0,20.0,120.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('93dfc1f6-e3d7-4c5a-8684-32534458bae9','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1660017600,'Update points, change prices, fix pdf display',1.0,20.0,20.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('24f10b26-5ccb-4217-ae89-11d601b16f67','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1660104000,'Add Penalty Reports to CMS',3.0,20.0,60.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('f0f74076-daed-4e92-9693-ede280cc3e19','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1672203600,'Server drive replacement/data recovery',4.0,20.0,80.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('40e280f9-1a60-4765-9d3c-bbd6f7546e0a','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1643605200,'Add PR support to CMS',4.5,20.0,90.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('c3160122-ac8c-4e1e-9f12-be70dae50d38','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1643691600,'Deploy PR update to CMS backend',3.0,20.0,60.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('1c23838c-134e-486a-8e94-7d2d085ce4b2','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1643778000,'Update database schema to support PR',2.0,20.0,40.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('c6c1d55b-0895-4e4c-9b48-2de18dd4b3a8','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1643778000,'Patch riverheadraceway.com frontend for PR',2.5,20.0,50.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('1ce0b765-4330-454e-b339-679d3a61560c','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1644123600,'Begin new schema for schedule upload',2.0,20.0,40.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('41f3b4a2-d0ac-4813-8c97-353151735140','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1644642000,'Prototype rules upload page',3.0,20.0,60.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('296f2767-f2a1-48ee-afdd-7d9e5a5d4373','89f677fb-ca0f-4d43-9547-d4da77f0f0ba',1644814800,'Rules CMS page backend dev',1.0,20.0,20.0,1752132163,0);
INSERT INTO beenvoice_invoice_item VALUES('6df444f9-9013-4ba4-889f-288687bf40cd','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679025600,'Website fixes, Orbits Suite Update (5.9)',4.0,20.0,80.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('b8b7d422-8eb4-4550-8b8c-75d1eebb606c','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679284800,'Backblaze B2 Backup setup for VMs/web',7.0,20.0,140.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('0a871404-dd23-47ae-9b53-4db1762424db','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679371200,'Install and provision Active Directory SRV',4.5,20.0,90.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('e6ae1b2c-e842-431d-b2d1-fbe46f0d29d5','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679457600,'Update BackBlaze configurations',2.0,20.0,40.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('f540b78f-10a7-4f10-9409-10f54eff831e','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679457600,'Website edits',1.0,20.0,20.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('7dd05c87-6a3e-40e6-836a-63dd7e22d52c','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679544000,'Remove policies from website',0.5,20.0,10.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('5dd99069-2388-4daa-b304-a5e6f000bbaa','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679544000,'Add dynamic roster to website',3.5,20.0,70.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('64f37c00-5f5f-49d3-85bc-786d083abc01','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679630400,'Update handicapping rules, modify reserved',1.5,20.0,30.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('f7845e1a-cfaf-4b97-9404-985c578cd35d','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1679889600,'Update CMOD rules, separate bandos',1.0,20.0,20.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('1967e5b2-98ae-493b-ba34-b28c81ebeed9','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1681272000,'Separate/ configure user accounts for FM',2.0,20.0,40.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('623d2cee-7c09-4626-bff7-16b4af75a3ac','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1681704000,'Generate and email RDP deployments',1.0,20.0,20.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('9a5cac21-e2f8-4028-b90d-2f1d1701abb6','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1681876800,'Generate roster CSV and convert to FM',2.0,20.0,40.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('dda3b050-8ad8-45fb-bee5-48bc2e94c469','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1682395200,'Troubleshoot FM access',1.5,20.0,30.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('2e562992-b42e-4d1d-99eb-ff354b2194d8','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1682568000,'Generate login certificates/install FM server',2.0,20.0,40.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('8542b97d-a5e5-4a95-b143-9677c9ca2c09','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1683000000,'Reset RDP cache on Vmix PC/initialize',1.5,20.0,30.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('54ba18de-ae79-4f36-a5f7-5e112e7033fe','2a07bf2e-1923-4b4b-aba9-14c507a2f2c4',1683086400,'Unify user accounts on AD for FM',2.5,20.0,50.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('6446d0af-4267-42fb-b929-18705adf748a','0b057a65-fe7d-4495-8756-4dd61f6895e1',1683777600,'On-site- printer and system update/config',7.0,20.0,140.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('967b4092-caf5-4fe9-94ae-9ad05d021abd','0b057a65-fe7d-4495-8756-4dd61f6895e1',1683950400,'Race day',7.0,20.0,140.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('d13204e9-14e8-4cf6-af8d-0d554f865897','0b057a65-fe7d-4495-8756-4dd61f6895e1',1684123200,'FM Maintenance/Web development',5.0,20.0,100.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('e3e9ce1b-ed84-47e3-822f-f844b7aa0484','0b057a65-fe7d-4495-8756-4dd61f6895e1',1684209600,'PointsSplitter Script (Remote)',1.5,20.0,30.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('eb535a1b-315f-4457-b742-72d01419b2cd','0b057a65-fe7d-4495-8756-4dd61f6895e1',1684296000,'vMix,New ticker and sponsors',5.0,20.0,100.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('8c318cea-7d7d-4ec5-a6df-63b46e1d36be','0b057a65-fe7d-4495-8756-4dd61f6895e1',1684382400,'Web Development (Remote)',4.0,20.0,80.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('6def29d4-4511-4705-b963-29717f881a7a','0b057a65-fe7d-4495-8756-4dd61f6895e1',1684468800,'MyRacePass/Website',5.0,20.0,100.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('2a3a5028-d561-43ac-af77-2a2af562b145','0b057a65-fe7d-4495-8756-4dd61f6895e1',1684641600,'Race day',5.0,20.0,100.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('d4cba322-8a53-4f72-9e10-16388bbc5e51','f86f4002-6539-44a3-b8c9-ca6689f809c1',1684728000,'MyRacePass Data/FM Server/3Play',4.5,20.0,90.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('d2180f5c-685d-4f5e-8a03-b8f6804bbf31','f86f4002-6539-44a3-b8c9-ca6689f809c1',1684814400,'FileMaker Troubleshooting/Maintenance',2.5,20.0,50.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('7feefdb6-1a66-439c-8013-a354d7af4284','f86f4002-6539-44a3-b8c9-ca6689f809c1',1684900800,'New graphics suite',5.5,20.0,110.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('d23b7924-9acc-48c4-9d09-067b6f12c0b6','f86f4002-6539-44a3-b8c9-ca6689f809c1',1685073600,'TV Lineups program',7.0,20.0,140.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('6bd45327-8f01-4dab-8a92-9b76363ce2d3','f86f4002-6539-44a3-b8c9-ca6689f809c1',1685160000,'Race Day',7.0,20.0,140.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('77d6c533-8d6b-437f-b3bb-7f51dd8f8e5b','f86f4002-6539-44a3-b8c9-ca6689f809c1',1685505600,'PC Maintenance',5.5,20.0,110.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('d83da575-2e45-4dd6-bf39-4f1b553a3d4f','f86f4002-6539-44a3-b8c9-ca6689f809c1',1685592000,'Web Development/CMS backend update',6.0,20.0,120.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('9dfc99e1-87b2-4487-90e2-3d7410bf771f','f86f4002-6539-44a3-b8c9-ca6689f809c1',1685592000,'Equipment Purchase - Black Box',1.0,170.0,170.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('f0ed5f32-fa79-43d8-bbb1-02859b9a9f7d','f86f4002-6539-44a3-b8c9-ca6689f809c1',1685678400,'TV setup and wiring',3.0,20.0,60.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('8e754e3f-eee4-4af7-93c4-1238f32d572c','f86f4002-6539-44a3-b8c9-ca6689f809c1',1685764800,'Race Day',3.0,20.0,60.0,1752132164,0);
INSERT INTO beenvoice_invoice_item VALUES('62ea502c-a52a-462d-93ed-8deb5b8b97af','ef6a5079-2d65-46b1-8d87-a9ef5c0cb650',1685937600,'Website updates, capture card fix',5.0,20.0,100.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('97e3d7c5-a2bc-484b-90ee-8883fafd6842','ef6a5079-2d65-46b1-8d87-a9ef5c0cb650',1686024000,'Web dev',4.0,20.0,80.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('c2fc47ee-9c96-4b66-9a87-fe52054ab6e7','ef6a5079-2d65-46b1-8d87-a9ef5c0cb650',1686110400,'Website work, Itinerary/Roster fixes',5.0,20.0,100.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('08aab334-1ef7-470e-b02b-99b8991cbf78','ef6a5079-2d65-46b1-8d87-a9ef5c0cb650',1686283200,'Quickbooks reinstall/drive copy (on-site)',1.0,20.0,20.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('17957359-ecdd-49be-8a23-257c7bc45e81','ef6a5079-2d65-46b1-8d87-a9ef5c0cb650',1686283200,'Web development',5.0,20.0,100.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('bc1cb3aa-3d66-4b6d-9089-6bdda101503c','ef6a5079-2d65-46b1-8d87-a9ef5c0cb650',1686369600,'Race Day',7.0,20.0,140.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('3489a122-5983-461f-bf54-edc0df82a89d','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1687492800,'Reset passwords, hide enduro points',1.5,20.0,30.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('afc213f9-5738-48a2-9be2-91264ee2fd70','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1688356800,'On-site website work',6.5,20.0,130.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('5c7c9b42-8da1-4cfe-a683-b2175588d4a0','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1688443200,'Remote website work',3.5,20.0,70.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('ee858f44-df11-4754-a105-418a0c392f5a','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1688443200,'Microsoft Office 2019 ProPlus',1.0,30.0,30.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('ddf90623-e1c7-4d77-b215-20e3bdcf057c','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1688529600,'On-site website work',6.0,20.0,120.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('62185930-638a-4de1-80f4-cb594af09848','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1688702400,'On-site website work',9.0,20.0,180.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('c4a6df10-ff6b-475f-9614-3ab87bc891dc','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1688788800,'Race Day',9.0,20.0,180.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('6c7cb646-294c-4279-bd78-986b84b99c01','9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb',1688875200,'Website work',3.0,20.0,60.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('b4575be6-65d8-435d-974f-e3a741500ba4','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1688961600,'On-site website work',7.0,20.0,140.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('f2282616-a3f4-4920-9d12-c89251d67468','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1689048000,'Remote website work',6.5,20.0,130.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('ede3c1c2-d80e-489a-945d-a61e24e15f1f','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1689134400,'Remote website work',7.0,20.0,140.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('af24fb33-cde7-4bb8-a0ba-b81a9fb6222c','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1689220800,'On-site computer work',4.0,20.0,80.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('1e16ad5f-a961-46ed-a58c-4423a830839c','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1689220800,'Remote website work',4.0,20.0,80.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('b575b193-ce8e-415c-8689-6a8fac8e7a1f','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1689307200,'On-site computer/website',7.0,20.0,140.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('2f4fd87b-4a2e-4ddb-88c3-770a36bf5640','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1689393600,'Race Day',6.0,20.0,120.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('b2525223-cf5e-4a2e-a07c-ba3972f51409','9186435f-2b62-4c58-aa45-c00aeac9c7d6',1689393600,'Acer SB220Q',1.0,80.0,80.0,1752132165,0);
INSERT INTO beenvoice_invoice_item VALUES('c859a866-6487-432d-ad05-cf6bc732c6c6','a722008f-f269-4018-b755-b25cd2c5471a',1658030400,'Website (off-site)',3.0,20.0,60.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('284084d7-cb14-40db-8017-99aa6182741f','a722008f-f269-4018-b755-b25cd2c5471a',1658116800,'Website (on-site)',6.0,20.0,120.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('ea1457ef-79a9-4d36-95e9-98667ab57de4','a722008f-f269-4018-b755-b25cd2c5471a',1658203200,'Website (off-site)',4.0,20.0,80.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('64d111a0-6371-44d5-994d-afd5e47491ca','a722008f-f269-4018-b755-b25cd2c5471a',1658289600,'Move ThinkCentre/Tires',7.0,20.0,140.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('a853c17d-bc3e-45a4-88d0-48ca01631e88','a722008f-f269-4018-b755-b25cd2c5471a',1658376000,'Audience Display and Points',5.0,20.0,100.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('8118f5c5-9570-4e93-ab07-9262ca30b3bb','a722008f-f269-4018-b755-b25cd2c5471a',1658376000,'Website (off-site)',4.0,20.0,80.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('55120798-2208-48e9-b617-804e595f35e7','a722008f-f269-4018-b755-b25cd2c5471a',1658462400,'Race Day',7.0,20.0,140.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('327193a6-9393-498e-8bb3-caff95069727','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690171200,'Website work and graphics',5.0,20.0,100.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('8ff3daae-0a76-4df9-b94f-7e1aa954a3aa','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690257600,'Website backend (off-site)',3.5,20.0,70.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('d2684ffe-51ab-4f20-8539-b7d1a1b76f87','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690344000,'Headshots and placeholders',5.5,20.0,110.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('c9bf1a18-b45e-4c5f-abf4-34cf709fe689','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690430400,'Lineups and auth security (off-site)',4.5,20.0,90.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('97cc7b06-caa1-4a75-b1f7-3f95ab0b5e19','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690516800,'Audience display, news editor, prices',6.0,20.0,120.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('6d9aeec4-a3de-4faa-be35-feacdb39e350','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690603200,'Price editor, begin database migration',6.0,20.0,120.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('eab32b2d-9edb-4a71-ad92-11d872857be9','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690689600,'Database migration, match up 2022 reg',6.0,20.0,120.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('0c133b57-d722-47a0-b390-c7ada5e555d9','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690776000,'Begin express registration (auto) (on-site)',3.0,20.0,60.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('3e8072e3-d462-492c-a32e-5bafa12ac66d','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690776000,'Finalize express registration (off-site)',4.0,20.0,80.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('1ef0a686-5c33-4bce-892e-b72cb4f6528a','ed3cf514-1438-4ee0-8e72-3f47c0f9aa15',1690862400,'Champion bios, rework points for new DB',6.0,20.0,120.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('9f75cdeb-4833-4325-abdf-f392c8be311b','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1690948800,'Race Day',8.5,20.0,170.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('8ca5bc48-ae7c-458d-9ae5-da54edb580bd','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691035200,'Website hotfixes',3.0,20.0,60.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('bf316f01-6d97-4887-bbb4-7f6bc04e1075','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691121600,'Tire/Office swap, website final touches',5.5,20.0,110.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('37f86e75-a336-4f7b-ae0c-345dd584d1a1','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691208000,'Race Day/Website publish',6.0,20.0,120.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('ddedea49-53f5-4d91-913d-48156ac2b4cc','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691380800,'Database fixes (on-site)',3.5,20.0,70.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('a1315055-0a28-4a70-9701-433201cd4870','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691380800,'Draft wall of champions page',3.0,20.0,60.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('4e858515-b73a-411e-90ca-605b396c7d9c','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691553600,'Webcam/Wall of champions (on-site)',6.0,20.0,120.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('e8121138-1871-41f0-8904-0f43ce5e4690','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691553600,'Wall of Champions Finalize/Publish',4.0,20.0,80.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('480dca64-e20f-43a9-8b6c-77acd8902f3d','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691640000,'Migrate to managed DB/Hall of Fame',7.5,20.0,150.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('34d04265-a18f-4ec7-9031-7141fe411c28','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691726400,'WiFi install, laptop setup, Add results to site',4.5,20.0,90.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('ce8e3f34-5c0b-4e51-80f9-13ef76a05e74','c7e84ee9-ae1e-4f31-b120-6cc7e02b0442',1691812800,'Race Day',5.0,20.0,100.0,1752132166,0);
INSERT INTO beenvoice_invoice_item VALUES('771c15eb-0062-43ca-9a72-cc76069cd02a','e18f8253-59a5-45ab-9070-8397930c8e12',1692676800,'Points repair',1.0,20.0,20.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('dfe1d780-2ce9-4aa3-bafe-692dfc5e4e3f','e18f8253-59a5-45ab-9070-8397930c8e12',1692936000,'Add JuiceBox division to site',2.0,20.0,40.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('d925981d-a0cc-4902-83e4-72cea6400014','e18f8253-59a5-45ab-9070-8397930c8e12',1693022400,'Prep site for ISP300 ticket/reg sale',2.5,20.0,50.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('7ac101d2-21a3-423b-ae3e-d7e796cad4cb','e18f8253-59a5-45ab-9070-8397930c8e12',1693972800,'Bring up old database site',2.0,20.0,40.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('30633312-1e3a-4bd6-9e36-d56a0c455a7c','e18f8253-59a5-45ab-9070-8397930c8e12',1694750400,'Fix registration car check',1.0,20.0,20.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('88f0b570-6406-42c8-adcb-fedd96bcbd1f','e18f8253-59a5-45ab-9070-8397930c8e12',1694836800,'Implement season ID system',2.0,20.0,40.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('cbc4efa9-f68e-44f9-b535-eee608c54a9e','e18f8253-59a5-45ab-9070-8397930c8e12',1695268800,'Update website content manager',2.0,20.0,40.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('e0e20b6f-dcd0-442c-a6c1-108c8a2d4c44','e18f8253-59a5-45ab-9070-8397930c8e12',1695355200,'Add toggle to event visibility, update events',3.0,20.0,60.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('e8a37197-cf61-4e50-a6ef-f4bc16caf583','e18f8253-59a5-45ab-9070-8397930c8e12',1696305600,'Design/implement BCA month graphics',2.5,20.0,50.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('53e884d4-0e44-47ae-9de1-a08e502166d8','e18f8253-59a5-45ab-9070-8397930c8e12',1696392000,'Create special event season/reg',4.0,20.0,80.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('d275522f-1659-473a-badc-70abe80aeb07','e18f8253-59a5-45ab-9070-8397930c8e12',1696478400,'Special event roster viewer',2.5,20.0,50.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('ccc37a05-5b94-4e8b-a804-167d7d86664e','e18f8253-59a5-45ab-9070-8397930c8e12',1696564800,'Add fee/payment process to special events',1.0,20.0,20.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('920b3a78-9957-4c32-b5ed-df46c297e5fc','e18f8253-59a5-45ab-9070-8397930c8e12',1696910400,'Email update (hide personal data from all)',3.0,20.0,60.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('85ff6150-7947-41d8-b408-1a816aa0fc76','e18f8253-59a5-45ab-9070-8397930c8e12',1697601600,'Update internal roster viewer for full data',2.0,20.0,40.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('8d4d28e2-db87-4a36-891c-2cee4b161bc9','e18f8253-59a5-45ab-9070-8397930c8e12',1697688000,'DB sanitization, prep for export 1099',1.5,20.0,30.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('0d68af50-8887-467a-b1d0-1071e2c479e3','e18f8253-59a5-45ab-9070-8397930c8e12',1698206400,'Add special event roster viewer to site',4.5,20.0,90.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('c84b3e48-f1b6-4199-bcdd-6f8685b2774f','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1698811200,'SE roster, change theme, update events.',4.0,20.0,80.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('e9a56413-22c6-4736-8e60-d510bb2ae953','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1698897600,'SE roster visibility, live DB detection',3.0,20.0,60.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('aa720f49-b51a-437b-a413-4a9f6a4f9544','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1699070400,'CMS RosterView Update',7.0,20.0,140.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('bf2c1ba6-8d19-4280-84ce-8173b863c23c','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1699246800,'CMS Backend Redesign (OOP)',4.0,20.0,80.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('704f9d4a-27d6-4b25-af11-43ac8211959b','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1699333200,'Various DB/Roster updates/exports',2.5,20.0,50.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('2bc45810-c0bb-4150-9191-e27efa42d7c4','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1700197200,'Shopify Website Design/Setup',4.5,20.0,90.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('208cea71-e378-494d-bcff-92c19ead51b7','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1700370000,'Special Event Mail Merge',2.5,20.0,50.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('a5f209a2-a65e-4e29-a137-4381bb477327','f39a6380-e1c0-4a28-b25e-f960e40ebbdc',1700456400,'Special Event Envelope Automation',1.0,20.0,20.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('95d8a07d-a5c2-4453-9795-c35cc7fc82b3','352863b6-4bcd-4060-9aee-7a1493381646',1701752400,'Compress all images for quicker site load',2.5,20.0,50.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('02d59723-bdd7-4bba-ba74-adfa0cfc7a16','352863b6-4bcd-4060-9aee-7a1493381646',1701838800,'Begin banquet registration',3.5,20.0,70.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('c58588d4-ac5b-424b-98ad-340157190c5e','352863b6-4bcd-4060-9aee-7a1493381646',1702357200,'Banquet registration database setup',2.0,20.0,40.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('407e13d3-4e35-4ec4-a9a0-95c0916193a0','352863b6-4bcd-4060-9aee-7a1493381646',1702443600,'Banquet reg stripe price generation',2.0,20.0,40.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('093d4237-83de-4d7f-9e5c-42a719726a03','352863b6-4bcd-4060-9aee-7a1493381646',1702616400,'Online store theming/UI',3.5,20.0,70.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('eaad59e0-1324-4a00-b443-d614fd56a227','352863b6-4bcd-4060-9aee-7a1493381646',1702702800,'Online store pricing/payment',2.0,20.0,40.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('36228c80-bc0a-4940-b088-4904b17899e7','352863b6-4bcd-4060-9aee-7a1493381646',1703566800,'Finalize banquet registration',5.5,20.0,110.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('a694d9db-b50c-4863-b552-c80b19f53222','352863b6-4bcd-4060-9aee-7a1493381646',1703653200,'Update champions and win tallys',2.0,20.0,40.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('22612fae-9421-40d9-900e-643638ca7531','352863b6-4bcd-4060-9aee-7a1493381646',1703826000,'Show prev rosters, add announcements',4.5,20.0,90.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('b39974ea-cd78-4271-b6f0-60c9b8c4911c','352863b6-4bcd-4060-9aee-7a1493381646',1703912400,'CMS banquet roster visibility',4.0,20.0,80.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('06bc2406-bbbe-4daa-96d7-d80151aa41e0','352863b6-4bcd-4060-9aee-7a1493381646',1704171600,'Hide registration for fixes, refund users',3.0,20.0,60.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('1adee95a-f05a-4f2c-b648-ee1af13ed1ff','352863b6-4bcd-4060-9aee-7a1493381646',1704517200,'Convert to store-pay-update for 2024 reg',3.0,20.0,60.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('066c172c-debd-4227-bbc1-0e4eb8d4d74e','352863b6-4bcd-4060-9aee-7a1493381646',1704603600,'Finalize and publish 2024 registration',5.0,20.0,100.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('3ae082a5-3e30-401c-8757-29306ae32dae','352863b6-4bcd-4060-9aee-7a1493381646',1704776400,'New events editor, disable letters for 2024',7.0,20.0,140.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('2b5c2d59-4611-4f69-8859-3f7e7d3b294e','352863b6-4bcd-4060-9aee-7a1493381646',1704862800,'Rules uploader',8.0,20.0,160.0,1752132167,0);
INSERT INTO beenvoice_invoice_item VALUES('05304002-9b6c-423b-bbee-4637d67041a5','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1704949200,'In-person Track Day',7.5,20.0,150.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('fd6e3b70-9198-4aa2-be41-f2186bfeb52a','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1705035600,'Banquet export and mail merge',2.0,20.0,40.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('4f662168-ed58-4fa5-99ae-d79eeeae201e','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1705208400,'Number reservations',1.0,20.0,20.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('90373154-537e-43a0-82a8-fcc036514461','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1705381200,'Division page hotfix',0.5,20.0,10.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('67fc6ed2-e430-4e28-90dc-c40bd7c2e3b4','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1705467600,'Auto display driver registrations',3.0,20.0,60.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('6d680a14-f864-4047-8c3b-ff6afcdaf10c','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1705640400,'Shopify Finances',2.0,20.0,40.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('c7ca7f6d-bba8-436f-9ecc-13b7e67993c7','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1705813200,'Banquet Mail Merge pt.2',2.0,20.0,40.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('18154b9a-0377-48ee-b3b6-64e0aafa45ff','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1705813200,'Banquet ticket close/clean up',1.0,20.0,20.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('6f2849cc-65d9-44fe-8b12-82c551fa71a2','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1706245200,'Take down schedule, fix event publisher',4.0,20.0,80.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('f0a627d2-5f3a-4a9c-ab54-f7da5a304b00','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1706590800,'Permissions, sponsor links',2.0,20.0,40.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('f0349c88-43ff-4dba-9cfe-5940713b1612','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1707022800,'Begin new roster viewer/editor',5.0,20.0,100.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('fcafdfbb-6f33-44d0-8044-4450b772b061','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1707109200,'Roster editor UI/Tables',4.0,20.0,80.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('f3b99670-1f60-4e87-bb80-95170ddd784f','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1707368400,'Roster editor,change participants/autofill',4.5,20.0,90.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('10134ca6-0e8a-4c41-a91b-13945a12a4cb','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1707454800,'Roster editor,Auto tax form generation',6.5,20.0,130.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('a3b9aaec-8ba1-49fc-b1a9-7506fd84460a','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1707627600,'Update CMS navigation',3.0,20.0,60.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('52460b23-e519-4fc6-ac89-46576070f9f3','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1707714000,'CMS User Manager/Perms editor',5.0,20.0,100.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('c80150ba-34fb-4b9b-a9a5-78024e7b5e40','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1708837200,'NASCAR Reg Link, general typos',0.5,20.0,10.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('4c88ca6d-482e-489c-9da9-16fa2cc8bd00','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1710129600,'Track day',4.0,20.0,80.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('5c8995f6-b191-4ba6-b129-0537785e156e','dc0e0595-07a8-471b-8f7b-23cd13c0b8c1',1710216000,'Event page custom links',2.0,20.0,40.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('0b2c3bc2-be20-4c16-b384-9d5bd1e2e693','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1710388800,'Track Day',3.5,20.0,70.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('78553d71-aa77-4791-8ec1-0d2b43973308','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1710475200,'Remote Onedrive Support',1.0,20.0,20.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('e7902386-266d-4b8a-85ce-47851e181d02','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1710907200,'Data collection/analysis for site',2.5,20.0,50.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('23c59227-54d9-43ad-9b34-a554b52ba74f','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1711339200,'Driver 1099/W-9 generation update',5.0,20.0,100.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('d18e53f0-ead0-4b56-b56c-be2b7671e7ea','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1711425600,'Itinerary search/export',2.0,20.0,40.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('51f1ebdd-f68b-40c6-83b7-d3b413882360','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1711512000,'Itinerary resend, Reg data export/merge',3.0,20.0,60.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('f4145173-a276-458c-a8d3-c8b94b5c4cf5','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1711598400,'Fix itinerary missing from website',3.0,20.0,60.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('6dd9e3c8-7def-48a2-840b-a72de7e1c753','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1711944000,'Roster/Itinerary updates',2.0,20.0,40.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('61b692a2-8c63-4061-9f35-30844a2cedd1','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1712548800,'Roster download link',1.0,20.0,20.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('a571bef7-b402-4316-b4db-209679d67fed','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1712721600,'Roster phone number export patch',2.5,20.0,50.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('38290567-dc1f-420c-8a74-1fda829e218d','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1712808000,'Stripe support contact/ticket',3.0,20.0,60.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('5835c23b-3872-45e8-b7fc-1e9884313a26','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1713153600,'Credit card charge match with stripe',1.5,20.0,30.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('f7e8504f-a95c-4921-9859-6f5c0687b1ad','cf6ea6c8-c485-4a01-aa12-f68306ef426a',1714017600,'Exit cleanup/account reassignment',3.0,20.0,60.0,1752132168,0);
INSERT INTO beenvoice_invoice_item VALUES('02602261-24d0-4546-88da-ff9fb14c3eed','1942364d-df4e-4175-8210-dbc202ca1038',1733979600,'Begin racehub-next development',4.5,25.0,112.5,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('c6334fbb-6892-4760-a61c-5cdc04921c72','1942364d-df4e-4175-8210-dbc202ca1038',1734066000,'Migrate basic features, authentication',4.0,25.0,100.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('33957386-0976-4800-a01d-2a5977e8df2a','1942364d-df4e-4175-8210-dbc202ca1038',1734498000,'Logistics planning and roadmap',1.0,25.0,25.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('dacda1bb-a445-4cdc-bdc5-db3bd1f48de1','1942364d-df4e-4175-8210-dbc202ca1038',1734670800,'Change racehub-php season, begin DB',3.0,25.0,75.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('d5bddee3-1892-4c7b-bab9-50598fcf7d83','1942364d-df4e-4175-8210-dbc202ca1038',1734757200,'Events page integration, rich homepage',5.5,25.0,137.5,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('3d80aba0-53d9-40d9-a163-5dc6aff36320','1942364d-df4e-4175-8210-dbc202ca1038',1734930000,'Create news page, optimize loading flow',5.0,25.0,125.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('f21fba61-87c1-4426-af99-450e42c193f5','1942364d-df4e-4175-8210-dbc202ca1038',1735016400,'Begin DigitalOcean provisioning/deploy',2.5,25.0,62.5,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('86ec004d-cc98-4c37-9943-1a7f60170d69','1942364d-df4e-4175-8210-dbc202ca1038',1735189200,'Deploy app/DB, news page optimizations',6.0,25.0,150.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('ab7cc962-5024-48e6-979a-885ccf6a7194','1942364d-df4e-4175-8210-dbc202ca1038',1735275600,'Fix deployment issues, integrate DO App',6.0,25.0,150.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('08d9e276-6dcf-4e50-9de5-dd13b580fe6f','1942364d-df4e-4175-8210-dbc202ca1038',1735362000,'Add image compression, content delivery',6.0,25.0,150.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('6a243898-3ef9-47f0-9008-9e3fca0a1c33','1942364d-df4e-4175-8210-dbc202ca1038',1735448400,'Announcements, Promo, Sponsors CMS',6.0,25.0,150.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('23059eac-34fb-44d2-9c36-1e10e387167d','1942364d-df4e-4175-8210-dbc202ca1038',1704171600,'Begin competitors page',3.0,25.0,75.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('9758e516-7655-4410-9f12-b069326ff3e2','1942364d-df4e-4175-8210-dbc202ca1038',1704258000,'Migrate APIs to tRPC for data security',4.0,25.0,100.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('628da274-f479-4603-bde2-9556795a6d4d','1942364d-df4e-4175-8210-dbc202ca1038',1704344400,'Recreate articles CMS for rich text',6.0,25.0,150.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('12a7c5ef-4029-410f-b176-a52966015698','1942364d-df4e-4175-8210-dbc202ca1038',1704430800,'Migrate announcements editor, add raindate',4.0,25.0,100.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('bea4d148-602b-4cc6-a1a9-4b9a7717c050','1942364d-df4e-4175-8210-dbc202ca1038',1704517200,'Discuss and plan out site scope (In-person)',2.0,25.0,50.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('d286c494-64c4-4eaf-9a8d-5ad681b4413b','1942364d-df4e-4175-8210-dbc202ca1038',1704517200,'Implement reports, rules, and champs',6.0,25.0,150.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('d43cedf4-a854-44ef-8853-725369212bd6','1942364d-df4e-4175-8210-dbc202ca1038',1704603600,'Add CMS authentication, route protection',6.5,25.0,162.5,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('fb557beb-9912-4e63-b883-8ff74451062b','1942364d-df4e-4175-8210-dbc202ca1038',1704690000,'Clean up deployment, fix UI/display bugs',3.0,25.0,75.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('1383248a-2301-4df4-985d-042cd44c1c49','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1736398800,'Correct rain date and sponsor editor saves',3.0,25.0,75.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('dff81591-7781-45a2-b7b4-2e729c15048b','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1736485200,'Fix bugs with article editor and images',4.0,25.0,100.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('01f8a30d-e04e-4ccc-ad18-da918e677ff9','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1736571600,'Add upload event image/compress for load',3.0,25.0,75.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('d2f51448-c17c-4dc1-bfb3-09f7af3f9d3a','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1736744400,'Work w/ hotlap to get registration roster',2.0,25.0,50.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('70f9a81a-a4c6-4c78-b80e-0b5a6b0123a0','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1736830800,'Add user management w/ email pwd reset',4.0,25.0,100.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('b06d988c-abb5-40a7-baad-f35878cf11e9','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1736917200,'Finalize code for public, deploy site, bkp old',6.5,25.0,162.5,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('b6ef7b4b-f43a-472d-abbb-49031e268e88','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737003600,'Add analytics for page views and clicks',4.0,25.0,100.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('4081c2cd-2af2-4283-9e37-5992557666c7','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737090000,'Track System Setup/Shopify (In-person)',3.0,25.0,75.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('29a894b6-46c4-4a01-a7c8-4ebe0fc9c0cd','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737176400,'Begin real-time banquet voting system',4.0,25.0,100.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('b8a10fea-3e9b-4885-ae1c-ef222a6584e4','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737435600,'extract/export W9 information for 2024',4.5,25.0,112.5,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('d78e4072-375c-41e2-8a81-69b7380b9d30','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737522000,'Implement 2024 roster for voting',3.0,25.0,75.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('735d00db-dd71-48a2-81dc-d4ab34dc3733','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737608400,'Test and complete deployment of voting',4.5,25.0,112.5,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('aa9f9359-04a1-4b47-8515-dec844564502','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737694800,'Push and enable banquet voting, fix bugs',3.0,25.0,75.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('881a19bf-b655-407d-9a52-1639ce13c5fe','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737781200,'Remove banquet voting, show points tables',3.0,25.0,75.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('c07aed16-5c22-4fde-9476-b8a8a7485572','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737867600,'CMS Reconfiguration for SS and MS class',2.5,25.0,62.5,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('726427ac-a5f0-4c05-9efd-0402fa6e30f0','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1737954000,'Competitors and division page redesign',5.0,25.0,125.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('874a1159-df26-4851-8dc5-d34509b25e77','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1738040400,'Browser conflict tests and fixes',3.5,25.0,87.5,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('b0fb99d3-9c32-4729-89ce-7aab0ba98256','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1738299600,'Rules CMS Editor upload and edit repair',4.5,25.0,112.5,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('33cda6b9-cdc6-4211-a52f-a6aa9badaf2f','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1738558800,'Migrate backup from BB to DO, sys updates',4.0,25.0,100.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('d1dfaa3d-c880-47c4-b2a8-5e1c61b72ae0','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1738645200,'Create and verify backup scripts',3.0,25.0,75.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('21627b66-05b7-472e-8df6-ddc37554bf3b','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1738731600,'Optimize devenv to use locally hosted S3/DB',2.0,25.0,50.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('271561b0-b8af-4603-aa43-49ba87bc4da6','547569b8-2f7c-486b-a4f1-2a7b80aa904a',1738904400,'Verify integrity of backup change chunks',1.0,25.0,25.0,1752132169,0);
INSERT INTO beenvoice_invoice_item VALUES('f4d05559-46e7-46da-8cf0-00606e63fb49','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1741150800,'Limit event display, update event layout',3.0,25.0,75.0,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('b7a66b38-1628-46cd-be21-0d9d0f7c105a','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1741579200,'Work w/ cloudflare to inc. file size limit',1.0,25.0,25.0,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('8a98405f-ff6b-4e64-83aa-25cf2ad0e3cb','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742097600,'update/fix article saving/loading process',4.0,25.0,100.0,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('80c17c33-bc48-44e3-b358-73dc7df0b63e','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742184000,'update/fix rule saving/loading process',2.5,25.0,62.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('d8f3066a-ea93-4221-8d9e-1921fb31d006','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742270400,'patch Next.JS emerg. security vulnerability',3.0,25.0,75.0,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('41c658b0-8020-4471-9e8d-e0f67108c9a9','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742356800,'Update server headers to use new limit',1.5,25.0,37.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('933bad4e-f7da-452c-b8f5-be6d631cbe23','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742702400,'Add PDF export of events/rules on demand',3.0,25.0,75.0,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('d204cb6a-be0e-4ee7-8c46-a7f532c7a291','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742788800,'Add file caching to save $ on server usage',3.0,25.0,75.0,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('7e172229-4a68-482c-b429-326e228d185e','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742875200,'Add video upload, begin driver testimonial',4.0,25.0,100.0,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('19c8f1e4-e676-40c2-ba1a-c370c2491af8','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1742961600,'Disable points section, prep for new points',2.5,25.0,62.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('1c26eeb4-22de-47bd-a170-d003fda1a213','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1743048000,'Finalize testimonial, update/enable points',5.0,25.0,125.0,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('c5aaf396-c27b-44ac-b141-c69872d87a4d','bd64542e-c576-4dd7-b0d4-f4d6077aef25',1743393600,'Retrieve and display previous itineraries',1.5,25.0,37.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('df365fb2-9d75-4589-83e4-48969e62df5d','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1746244800,'Lineups upload interface finalized/pushed',4.0,25.0,100.0,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('0a35f2d9-15b1-4d82-9ba1-df27f0024f6f','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1746331200,'Lineup audience display',3.75,25.0,93.75,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('6d6502d2-0f0d-4521-8943-4ae78e5bc7d9','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1746417600,'Lineup mobile display',4.5,25.0,112.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('258f123f-f80b-4920-af38-08bc8d163f5e','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1746849600,'Begin points upload system backend',2.5,25.0,62.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('c517d481-9741-4283-b74c-e61b500cfd2c','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1747108800,'FileMaker points parsing logic',2.0,25.0,50.0,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('4e5e6815-6b4b-4433-8a52-dafcbcdd7284','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1747195200,'Update spectator policy system',1.5,25.0,37.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('fa70e962-0678-4593-b8fd-8abab5a26c6b','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1747454400,'Restructure lineup page logic for old phone',3.5,25.0,87.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('9e1878f9-f485-408e-91c9-281b02737d3e','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1747540800,'Handle cross time zone errors w/ lineups',2.5,25.0,62.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('8effbfe4-1434-4448-b7a6-5ab316fc93f9','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1747800000,'Crate mod points issue fix',1.0,25.0,25.0,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('12ea8e17-eac6-42b4-aa22-3981003172a5','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748404800,'In person, website/network planning',4.5,25.0,112.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('c8bd20a4-19d0-47ca-b381-93bd6e5fd2dc','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748491200,'Rain date API integration/management',5.0,25.0,125.0,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('02ce3809-d900-4f1f-9400-64b225d61339','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748577600,'Begin lineup patches for visibility',4.5,25.0,112.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('8f4d886d-8d3d-4a30-bec7-b41ee854f731','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748664000,'Bind rain dates to events, show reschedule',5.5,25.0,137.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('6fce0331-9208-408f-8369-4fb4a2fb2fa4','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748750400,'drag and drop lineups, divisions cms update',4.5,25.0,112.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('6f49db90-fa25-44ab-9ef4-57c00c9c36c3','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748836800,'home page reordering, QoL improvements',4.5,25.0,112.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('c63d9d91-6e0c-48f8-b2f1-a02c4839848c','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748923200,'In person, bulk email system',3.5,25.0,87.5,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('61b3faf1-4edc-4b05-9914-45fa8b49b51f','5a8f214c-8f6d-46e9-949e-1e9e31c40974',1748923200,'Remote, bulk email/delta points',3.0,25.0,75.0,1752132170,0);
INSERT INTO beenvoice_invoice_item VALUES('c236b466-5706-4bad-8324-5219c17dd2f2','06c43197-9685-4116-b83b-1c76840905ab',1652500800,'Replay Operator',10.0,40.0,400.0,1752132902,0);
INSERT INTO beenvoice_invoice_item VALUES('772bdeaa-a5a9-4c7e-8a54-d02b6d115e16','a66739ec-fbfe-4871-8388-0b34b2228889',1683777600,'Install and configure tech PCs',2.0,20.0,40.0,1752132902,0);
INSERT INTO beenvoice_invoice_item VALUES('d88ffb8e-4c29-4882-8dff-dd2d227b1639','a66739ec-fbfe-4871-8388-0b34b2228889',1683950400,'Tire shack sales/maintenance',2.0,20.0,40.0,1752132902,0);
INSERT INTO beenvoice_invoice_item VALUES('66eeb92c-ecf3-46c1-b6f6-6569b90fe598','a66739ec-fbfe-4871-8388-0b34b2228889',1684123200,'Tire program/scanning',1.0,20.0,20.0,1752132902,0);
INSERT INTO beenvoice_invoice_item VALUES('8f00b60d-5dc7-4f19-ad3e-2a51d1c4d296','d6a1da99-d066-4993-b907-1e30a769f107',1743652800,'Correct time-zone errs for non-EST viewers',2.0,25.0,50.0,1752274548,0);
INSERT INTO beenvoice_invoice_item VALUES('3ab632b7-cebc-49a0-8f59-9f39db3c9543','d6a1da99-d066-4993-b907-1e30a769f107',1743912000,'WiFi Setup/Security Updates across sites',2.0,25.0,50.0,1752274548,1);
INSERT INTO beenvoice_invoice_item VALUES('a3cc32fd-a0aa-4986-8ec6-91e6572ed13d','d6a1da99-d066-4993-b907-1e30a769f107',1744084800,'Standardize date handling, data utility upd.',3.5,25.0,87.5,1752274548,2);
INSERT INTO beenvoice_invoice_item VALUES('f5009d53-27e0-4104-bde3-afaeb4c924e7','d6a1da99-d066-4993-b907-1e30a769f107',1744776000,'Rephrase/reorganize home page',2.5,25.0,62.5,1752274548,3);
INSERT INTO beenvoice_invoice_item VALUES('d92fb22e-e0fb-4e82-b2f9-27f8eee5a150','d6a1da99-d066-4993-b907-1e30a769f107',1744862400,'Add ability to remove/submit null timeslots',3.0,25.0,75.0,1752274548,4);
INSERT INTO beenvoice_invoice_item VALUES('a2228d31-0c4c-49f7-ba7c-a09eb4dfe2c5','d6a1da99-d066-4993-b907-1e30a769f107',1744948800,'Hostway email contact investigate/upload',2.5,25.0,62.5,1752274548,5);
INSERT INTO beenvoice_invoice_item VALUES('c5dcc389-3fea-4cfa-98eb-2130016be99a','d6a1da99-d066-4993-b907-1e30a769f107',1745035200,'Re-render live schedule, update deps.',4.0,25.0,100.0,1752274548,6);
INSERT INTO beenvoice_invoice_item VALUES('73386e72-750e-4eb1-83de-e239c66102fe','d6a1da99-d066-4993-b907-1e30a769f107',1745467200,'Add rich text editor to site backend',3.5,25.0,87.5,1752274548,7);
INSERT INTO beenvoice_invoice_item VALUES('87aa98a8-131d-49bb-98fb-0460a8dde4ab','d6a1da99-d066-4993-b907-1e30a769f107',1745553600,'Update mobile view, fix rules pagination',2.0,25.0,50.0,1752274548,8);
INSERT INTO beenvoice_invoice_item VALUES('6fe10405-029e-4164-b918-f521d3830818','d6a1da99-d066-4993-b907-1e30a769f107',1745812800,'Lineups backend port from racehub-php',2.0,25.0,50.0,1752274548,9);
INSERT INTO beenvoice_invoice_item VALUES('62f2594f-0d24-405a-989c-2fcb5392a3e6','d6a1da99-d066-4993-b907-1e30a769f107',1745899200,'Update filemaker, add csv export/import',2.5,25.0,62.5,1752274548,10);
INSERT INTO beenvoice_invoice_item VALUES('208eebce-58e5-4d1a-8088-47a516fe39c9','d6a1da99-d066-4993-b907-1e30a769f107',1745985600,'Wireframe/basic lineups user interface',3.5,25.0,87.5,1752274548,11);
INSERT INTO beenvoice_invoice_item VALUES('cf1c9e48-bf50-4083-b482-9338a3c439d0','0c9a6715-70f8-4f83-ab01-a8340773431d',1749096000,'Enhance PointsUpload page',3.5,25.0,87.5,1752278188,0);
INSERT INTO beenvoice_invoice_item VALUES('212d7b08-2d12-449a-a0f9-c4496819b740','0c9a6715-70f8-4f83-ab01-a8340773431d',1749441600,'Handle ties in points section',3.5,25.0,87.5,1752278188,1);
INSERT INTO beenvoice_invoice_item VALUES('0d6d372f-6679-4dea-b78b-03ef0192c1e4','0c9a6715-70f8-4f83-ab01-a8340773431d',1749528000,'Add manipulation of bulk email contact lists',4.0,25.0,100.0,1752278188,2);
INSERT INTO beenvoice_invoice_item VALUES('58dfc4ef-8498-4630-a62f-b5fd20410e6e','0c9a6715-70f8-4f83-ab01-a8340773431d',1749614400,'Add staff list to email system, create new',3.5,25.0,87.5,1752278188,3);
INSERT INTO beenvoice_invoice_item VALUES('513c952b-c0f7-49ee-948d-41e5ca4d6e83','0c9a6715-70f8-4f83-ab01-a8340773431d',1749700800,'Add rain banner functionality to events',4.0,25.0,100.0,1752278188,4);
INSERT INTO beenvoice_invoice_item VALUES('469256a8-8335-48ce-a001-67928accf01c','0c9a6715-70f8-4f83-ab01-a8340773431d',1750046400,'Social Media code of conduct',2.0,25.0,50.0,1752278188,5);
INSERT INTO beenvoice_invoice_item VALUES('30720638-2128-4017-897a-8d635d541246','0c9a6715-70f8-4f83-ab01-a8340773431d',1750219200,'Active status management, event cleanup',3.75,25.0,93.75,1752278188,6);
INSERT INTO beenvoice_invoice_item VALUES('d403fc8d-72d3-4d75-a91e-9b3cf68df820','0c9a6715-70f8-4f83-ab01-a8340773431d',1750305600,'Google/Apple Calendar Sync from events',4.5,25.0,112.5,1752278188,7);
INSERT INTO beenvoice_invoice_item VALUES('217f013d-861a-406e-bd8e-392659f6ba72','0c9a6715-70f8-4f83-ab01-a8340773431d',1750392000,'In person, printers/email/server updates',5.0,25.0,125.0,1752278188,8);
INSERT INTO beenvoice_invoice_item VALUES('52be1c1f-3523-4bc3-a8ab-66902db5e229','0c9a6715-70f8-4f83-ab01-a8340773431d',1750478400,'Race day, Server/Handicapping',6.0,25.0,150.0,1752278188,9);
INSERT INTO beenvoice_invoice_item VALUES('dee51491-b6b1-4038-a641-d4fcdfe42f95','0c9a6715-70f8-4f83-ab01-a8340773431d',1750651200,'Repair sponsors/Plan out permissions',3.5,25.0,87.5,1752278188,10);
INSERT INTO beenvoice_invoice_item VALUES('0bd1bec4-2541-42db-ae38-d86d9bac43d5','0c9a6715-70f8-4f83-ab01-a8340773431d',1750737600,'Backend permissions implementation',5.5,25.0,137.5,1752278188,11);
INSERT INTO beenvoice_invoice_item VALUES('dbcb12d5-9b37-4f65-9275-56d82338601b','0c9a6715-70f8-4f83-ab01-a8340773431d',1750824000,'Frontend permissions/deployment',5.0,25.0,125.0,1752278188,12);
INSERT INTO beenvoice_invoice_item VALUES('2899f8ae-6f76-4f32-8350-09151b3d76ab','0c9a6715-70f8-4f83-ab01-a8340773431d',1750910400,'Plan out and begin migration to races sys',4.5,25.0,112.5,1752278188,13);
INSERT INTO beenvoice_invoice_item VALUES('f6f46a67-83ac-4bb1-b128-82daf0063128','0c9a6715-70f8-4f83-ab01-a8340773431d',1750996800,'Replace eventDivisions with races',5.0,25.0,125.0,1752278188,14);
INSERT INTO beenvoice_invoice_item VALUES('56e676ae-3de1-4039-b3d6-e5da99c5aa0c','0c9a6715-70f8-4f83-ab01-a8340773431d',1751083200,'In person, race day, media, development',8.0,25.0,200.0,1752278188,15);
INSERT INTO beenvoice_invoice_item VALUES('71fb8bc8-ac75-426b-a624-83bbaebbac1c','0c9a6715-70f8-4f83-ab01-a8340773431d',1751169600,'User interface for race editing',5.5,25.0,137.5,1752278188,16);
INSERT INTO beenvoice_invoice_item VALUES('eb64faf3-2a9b-4f66-8dd9-4f39f6a7af05','0c9a6715-70f8-4f83-ab01-a8340773431d',1751256000,'Public user interface for finishes and lineup',5.5,25.0,137.5,1752278188,17);
INSERT INTO beenvoice_invoice_item VALUES('79b80323-6c8a-4562-a274-f9e697b1efe4','0c9a6715-70f8-4f83-ab01-a8340773431d',1751342400,'Production push pt.1',6.0,25.0,150.0,1752278188,18);
INSERT INTO beenvoice_invoice_item VALUES('cd84469d-f608-4edd-9121-4366041fe25a','0c9a6715-70f8-4f83-ab01-a8340773431d',1751428800,'Production database migration',3.0,25.0,75.0,1752278188,19);
INSERT INTO beenvoice_invoice_item VALUES('93d21511-d3f9-4338-8eb4-3233614c4ae0','0c9a6715-70f8-4f83-ab01-a8340773431d',1751774400,'Testing, data entry from old races begin',4.0,25.0,100.0,1752278188,20);
INSERT INTO beenvoice_invoice_item VALUES('e701eb75-8ce0-4194-812a-2a3520487a00','0c9a6715-70f8-4f83-ab01-a8340773431d',1751860800,'Update pricing queries, 2023 races',4.0,25.0,100.0,1752278188,21);
CREATE TABLE `beenvoice_invoice` (
`id` text(255) PRIMARY KEY NOT NULL,
`invoiceNumber` text(100) NOT NULL,
`clientId` text(255) NOT NULL,
`issueDate` integer NOT NULL,
`dueDate` integer NOT NULL,
`status` text(50) DEFAULT 'draft' NOT NULL,
`totalAmount` real DEFAULT 0 NOT NULL,
`notes` text(1000),
`createdById` text(255) NOT NULL,
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
`updatedAt` integer, `taxRate` real NOT NULL DEFAULT 0, `businessId` text(255),
FOREIGN KEY (`clientId`) REFERENCES `beenvoice_client`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
);
INSERT INTO beenvoice_invoice VALUES('76d570fe-bfec-47bd-a7fa-b4ee8133c78e','INV-20210417-131231','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1618617600,1621209600,'paid',220.0,'Imported from CSV: 2021-04-17.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132158,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('61c3d28c-5031-4372-86e3-5bf895411046','INV-20210508-131255','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1620432000,1623024000,'paid',320.0,'Imported from CSV: 2021-05-08.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132159,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('57fcd73a-0876-4e91-9856-0f9c9695fcd1','INV-20210605-131278','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1622851200,1625443200,'paid',300.0,'Imported from CSV: 2021-06-05.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132159,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('555b1f8c-7a57-427f-bea8-ee4a3f2a2bf2','INV-20210714-131301','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1626220800,1628812800,'paid',510.0,'Imported from CSV: 2021-07-14.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132159,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('4fb5d8be-2588-4187-955d-e7643b08619f','INV-20210807-131324','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1628294400,1630886400,'paid',280.0,'Imported from CSV: 2021-08-07.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132160,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('f48104da-1baa-4a70-9d0c-c03f4017f60d','INV-20210825-131337','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1629849600,1632441600,'paid',450.0,'Imported from CSV: 2021-08-25.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132160,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('d0c7c941-d0e4-4d55-b1e5-10ed71f1c9f5','INV-20210921-131348','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1632182400,1634774400,'paid',340.0,'Imported from CSV: 2021-09-21.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132160,1752133428,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('6c4314c7-7bc7-4d8a-9513-59a1ebcfd890','INV-20211201-131360','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1638316800,1640908800,'paid',200.0,'Imported from CSV: 2021-12-01.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132161,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('b018eaca-b4b1-4c96-8e40-2a1ab5211e48','INV-20220422-131373','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1650585600,1653177600,'paid',250.0,'Imported from CSV: 2022-04-22.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132161,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('a0da2a05-5681-46fd-b988-235ec24971e2','INV-20220514-131387','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1652486400,1655078400,'paid',200.0,'Imported from CSV: 2022-05-14.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132162,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('713a368a-f7de-4de8-95dd-2a4a2d626fa1','INV-20220521-131401','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1653091200,1655683200,'paid',540.0,'Imported from CSV: 2022-05-21.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132162,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('fac3b7e2-9816-459c-960e-ac520b3f2cd5','INV-20220607-131419','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1654560000,1657152000,'paid',460.0,'Imported from CSV: 2022-06-07.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132162,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('8704d2fe-8972-4dae-8062-2f5b81e14493','INV-20220630-131436','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1656547200,1659139200,'paid',600.0,'Imported from CSV: 2022-06-30.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132163,1752133428,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('babfc847-b37d-44f2-91a9-4251691c11b4','INV-20220731-131453','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1659225600,1661817600,'paid',820.0,'Imported from CSV: 2022-07-31.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132163,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('89f677fb-ca0f-4d43-9547-d4da77f0f0ba','INV-20230316-131472','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1678924800,1681516800,'paid',520.0,'Imported from CSV: 2023-03-16.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132163,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('2a07bf2e-1923-4b4b-aba9-14c507a2f2c4','INV-20230513-131490','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1683936000,1686528000,'paid',750.0,'Imported from CSV: 2023-05-13.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132164,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('0b057a65-fe7d-4495-8756-4dd61f6895e1','INV-20230521-131513','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1684627200,1687219200,'paid',790.0,'Imported from CSV: 2023-05-21.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132164,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('f86f4002-6539-44a3-b8c9-ca6689f809c1','INV-20230604-131532','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1685836800,1688428800,'paid',1050.0,'Imported from CSV: 2023-06-04.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132164,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('ef6a5079-2d65-46b1-8d87-a9ef5c0cb650','INV-20230611-131552','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1686441600,1689033600,'paid',540.0,'Imported from CSV: 2023-06-11.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132165,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('9da8c49f-f6e5-4e5d-8da6-2c1baaf1e5cb','INV-20230709-131574','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1688860800,1691452800,'paid',800.0,'Imported from CSV: 2023-07-09.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132165,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('9186435f-2b62-4c58-aa45-c00aeac9c7d6','INV-20230717-131599','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1689552000,1692144000,'paid',910.0,'Imported from CSV: 2023-07-17.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132165,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('a722008f-f269-4018-b755-b25cd2c5471a','INV-20230722-131624','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1689984000,1692576000,'paid',720.0,'Imported from CSV: 2023-07-22.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132166,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('ed3cf514-1438-4ee0-8e72-3f47c0f9aa15','INV-20230801-131649','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1690848000,1693440000,'paid',990.0,'Imported from CSV: 2023-08-01.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132166,1752133428,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('c7e84ee9-ae1e-4f31-b120-6cc7e02b0442','INV-20230812-131677','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1691798400,1694390400,'paid',1130.0,'Imported from CSV: 2023-08-12.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132166,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('e18f8253-59a5-45ab-9070-8397930c8e12','INV-20231025-131707','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1698192000,1700787600,'paid',730.0,'Imported from CSV: 2023-10-25.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132167,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('f39a6380-e1c0-4a28-b25e-f960e40ebbdc','INV-20231120-131737','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1700438400,1703030400,'paid',570.0,'Imported from CSV: 2023-11-20.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132167,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('352863b6-4bcd-4060-9aee-7a1493381646','INV-20240110-131769','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1704844800,1707436800,'paid',1150.0,'Imported from CSV: 2024-01-10.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132167,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('dc0e0595-07a8-471b-8f7b-23cd13c0b8c1','INV-20240314-131797','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1710374400,1712966400,'paid',1190.0,'Imported from CSV: 2024-03-14.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132168,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('cf6ea6c8-c485-4a01-aa12-f68306ef426a','INV-20240425-131828','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1714003200,1716595200,'paid',660.0,'Imported from CSV: 2024-04-25.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132168,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('1942364d-df4e-4175-8210-dbc202ca1038','INV-20250108-131858','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1736294400,1738886400,'paid',2100.0,'Imported from CSV: 2025-01-08.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132169,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('547569b8-2f7c-486b-a4f1-2a7b80aa904a','INV-20250207-131897','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1738886400,1741478400,'paid',1925.0,'Imported from CSV: 2025-02-07.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132169,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('bd64542e-c576-4dd7-b0d4-f4d6077aef25','INV-20250402-131932','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1743552000,1746144000,'paid',850.0,'Imported from CSV: 2025-04-02.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132170,1752133428,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('d6a1da99-d066-4993-b907-1e30a769f107','INV-20250501-132029','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1746057600,1748649600,'paid',825.0,'Imported from CSV: 2025-05-01.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132170,1752274548,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('5a8f214c-8f6d-46e9-949e-1e9e31c40974','INV-20250604-132064','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1748995200,1751587200,'paid',1506.25,'Imported from CSV: 2025-06-04.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132170,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('0c9a6715-70f8-4f83-ab01-a8340773431d','INV-20250702-132103','1c17bccd-3bc6-42c2-a500-68728a2a9d25',1751414400,1754006400,'sent',2481.25,'Imported from CSV: 2025-07-02.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132171,1752278188,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('06c43197-9685-4116-b83b-1c76840905ab','INV-1752132853225','8c24c053-9f84-49be-95e3-30fe9cdcdeef',1652500800,1655179200,'paid',400.0,'Imported from CSV: 2022-05-14-NBC.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132902,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
INSERT INTO beenvoice_invoice VALUES('a66739ec-fbfe-4871-8388-0b34b2228889','INV-1752132853250','81edd8a8-c5c7-4f16-ab71-0efedbe3aff7',1684641600,1687320000,'paid',100.0,'Imported from CSV: 2023-05-21-hoosier.csv','1ca66210-7d70-43d1-b01b-07004f566ac8',1752132902,1752133427,0.0,'20ef93d6-b1c4-4f9a-b1c1-e62423770f6b');
CREATE TABLE `beenvoice_session` (
`sessionToken` text(255) PRIMARY KEY NOT NULL,
`userId` text(255) NOT NULL,
`expires` integer NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
);
CREATE TABLE `beenvoice_user` (
`id` text(255) PRIMARY KEY NOT NULL,
`name` text(255),
`email` text(255) NOT NULL,
`password` text(255),
`emailVerified` integer DEFAULT (unixepoch()),
`image` text(255)
);
INSERT INTO beenvoice_user VALUES('1ca66210-7d70-43d1-b01b-07004f566ac8','Sean O''Connor','sean@soconnor.dev','$2b$12$ntXp5nKRyNyf9HzQFaodVO/yjKHjCW6lG0.MiIH0U74o4y15Jz0Cu',1752122289,NULL);
INSERT INTO beenvoice_user VALUES('08305460-ee86-430b-aa8b-a5280b4a1d5b','Test User','test@example.com','$2b$12$Qh7kl3I0poJCBlitIm9HeumOPCh0zRdgl161KrCyxTNeVi979Lb7C',1752122648,NULL);
CREATE TABLE `beenvoice_verification_token` (
`identifier` text(255) NOT NULL,
`token` text(255) NOT NULL,
`expires` integer NOT NULL,
PRIMARY KEY(`identifier`, `token`)
);
CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at numeric
);
INSERT INTO __drizzle_migrations VALUES(NULL,'01ee87b5282b51988c94170329f6261297481122c93e3c45ac216f0d9a2275f4',1752251358024);
INSERT INTO __drizzle_migrations VALUES(NULL,'6c12a89fdba3169518236b650fa5cbbaff2bff0ac67a4ee5c717295135c1b0a0',1752268902130);
CREATE TABLE IF NOT EXISTS "beenvoice_client" (
`id` text(255) PRIMARY KEY NOT NULL,
`name` text(255) NOT NULL,
`email` text(255),
`phone` text(50),
`addressLine1` text(255),
`addressLine2` text(255),
`city` text(100),
`state` text(50),
`postalCode` text(20),
`country` text(100),
`createdById` text(255) NOT NULL,
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
`updatedAt` integer,
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
);
INSERT INTO beenvoice_client VALUES('81edd8a8-c5c7-4f16-ab71-0efedbe3aff7','Hoosier Tire of Calverton','ar@riverheadraceway.com','(631) 842-7223','1797 Old Country Rd','','Riverhead','NY','11901','','1ca66210-7d70-43d1-b01b-07004f566ac8',1752129038,1752129178);
INSERT INTO beenvoice_client VALUES('1c17bccd-3bc6-42c2-a500-68728a2a9d25','Riverhead Raceway','ar@riverheadraceway.com','(631) 842-7223','1797 Old Country Rd','','Riverhead','NY','11901','United States','1ca66210-7d70-43d1-b01b-07004f566ac8',1752129251,1752129251);
INSERT INTO beenvoice_client VALUES('8c24c053-9f84-49be-95e3-30fe9cdcdeef','TDE, Inc.','tvtimd@aol.com','(413) 575-6125','116 Dowd Ct','','Ludlow','MA','01056','United States','1ca66210-7d70-43d1-b01b-07004f566ac8',1752129474,1752129474);
CREATE TABLE `beenvoice_business` (
`id` text(255) PRIMARY KEY NOT NULL,
`name` text(255) NOT NULL,
`email` text(255),
`phone` text(50),
`addressLine1` text(255),
`addressLine2` text(255),
`city` text(100),
`state` text(50),
`postalCode` text(20),
`country` text(100),
`website` text(255),
`taxId` text(100),
`logoUrl` text(500),
`isDefault` integer DEFAULT false,
`createdById` text(255) NOT NULL,
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
`updatedAt` integer,
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
);
INSERT INTO beenvoice_business VALUES('20ef93d6-b1c4-4f9a-b1c1-e62423770f6b','Sean O''Connor','sean.oconnor@riverheadraceway.com','(631) 601-6555','14 Washington Avenue','','Miller Place','NY','11764','United States','https://soconnor.dev','','',1,'1ca66210-7d70-43d1-b01b-07004f566ac8',1752277286,1752277286);
CREATE INDEX `account_user_id_idx` ON `beenvoice_account` (`userId`);
CREATE INDEX `invoice_item_invoice_id_idx` ON `beenvoice_invoice_item` (`invoiceId`);
CREATE INDEX `invoice_item_date_idx` ON `beenvoice_invoice_item` (`date`);
CREATE INDEX `invoice_client_id_idx` ON `beenvoice_invoice` (`clientId`);
CREATE INDEX `invoice_created_by_idx` ON `beenvoice_invoice` (`createdById`);
CREATE INDEX `invoice_number_idx` ON `beenvoice_invoice` (`invoiceNumber`);
CREATE INDEX `invoice_status_idx` ON `beenvoice_invoice` (`status`);
CREATE INDEX `session_userId_idx` ON `beenvoice_session` (`userId`);
CREATE INDEX `client_name_idx` ON `beenvoice_client` (`name`);
CREATE INDEX `client_email_idx` ON `beenvoice_client` (`email`);
CREATE INDEX `invoice_item_position_idx` ON `beenvoice_invoice_item` (`position`);
CREATE INDEX `client_created_by_idx` ON `beenvoice_client` (`createdById`);
CREATE INDEX `business_created_by_idx` ON `beenvoice_business` (`createdById`);
CREATE INDEX `business_name_idx` ON `beenvoice_business` (`name`);
CREATE INDEX `business_email_idx` ON `beenvoice_business` (`email`);
CREATE INDEX `business_is_default_idx` ON `beenvoice_business` (`isDefault`);
CREATE INDEX `invoice_business_id_idx` ON `beenvoice_invoice` (`businessId`);
COMMIT;
+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:
+47
View File
@@ -0,0 +1,47 @@
services:
app:
build:
context: .
image: beenvoice:local
environment:
NODE_ENV: production
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
ports:
- "${WEB_PORT:-3000}:3000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes:
- beenvoice_pg_data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
volumes:
beenvoice_pg_data:
+281
View File
@@ -0,0 +1,281 @@
# Enhanced Email Sending Features
## Overview
The beenvoice application now includes a comprehensive email sending system with preview, rich text editing, and confirmation features. This enhancement provides a professional email experience for sending invoices to clients.
## Features
### 🎨 Rich Text Email Composer
- **Tiptap Editor Integration**: Professional rich text editing with formatting options
- **Text Formatting**: Bold, italic, strikethrough, and color options
- **Text Alignment**: Left, center, and right alignment
- **Lists**: Bullet points and numbered lists
- **Color Picker**: Choose from a variety of text colors
- **Real-time Preview**: See changes as you type
### 👁️ Email Preview
- **Visual Preview**: See exactly how your email will appear to recipients
- **Invoice Summary**: Displays key invoice details (number, date, amount)
- **Attachment Notice**: Shows PDF attachment information
- **Professional Styling**: Clean, branded email template
- **Responsive Design**: Optimized for all screen sizes with proper text wrapping
- **Mobile-First**: Touch-friendly interface with proper spacing
### ✅ Send Confirmation
- **Two-Step Process**: Compose ↔ Preview with Send Action
- **Action-Based Sending**: Send button available from sidebar and floating action bar
- **Status Updates**: Automatic status change from draft to sent
- **Error Handling**: Clear error messages with specific guidance
- **SSR Compatible**: Proper hydration handling for server-side rendering
### 📄 Smart Templates
- **Auto-Generated Content**: Professional email templates with proper paragraph spacing
- **Time-Based Greetings**: Morning, afternoon, or evening greetings
- **Invoice Details**: Automatically includes invoice number, date, and amount
- **Business Branding**: Uses your business name and contact information
- **Immediate Loading**: Content appears instantly in the editor without requiring tab switching
## Components
### EmailComposer
**Location**: `src/components/forms/email-composer.tsx`
A rich text editor component for composing emails with formatting options.
**Props**:
- `subject`: Email subject line
- `onSubjectChange`: Callback for subject changes
- `content`: Email content (HTML)
- `onContentChange`: Callback for content changes
- `fromEmail`: Sender email address
- `toEmail`: Recipient email address
### EmailPreview
**Location**: `src/components/forms/email-preview.tsx`
Displays a visual preview of how the email will appear to recipients.
**Props**:
- `subject`: Email subject line
- `fromEmail`: Sender email address
- `toEmail`: Recipient email address
- `content`: Email content (HTML)
- `invoice`: Invoice data for summary display
### SendEmailDialog
**Location**: `src/components/forms/send-email-dialog.tsx`
Main dialog component that combines composition, preview, and confirmation.
**Props**:
- `invoiceId`: ID of the invoice to send
- `trigger`: React element that opens the dialog
- `invoice`: Invoice data
- `onEmailSent`: Callback when email is successfully sent
### EnhancedSendInvoiceButton
**Location**: `src/components/forms/enhanced-send-invoice-button.tsx`
Enhanced button component that opens the email dialog.
**Props**:
- `invoiceId`: ID of the invoice to send
- `variant`: Button style variant
- `className`: Additional CSS classes
- `showResend`: Whether to show "Resend" text
- `size`: Button size
## API Enhancements
### Enhanced Email Router
**Location**: `src/server/api/routers/email.ts`
The email API has been enhanced to support custom content and HTML emails.
**New Parameters**:
- `customSubject`: Optional custom email subject
- `customContent`: Optional custom email content (HTML)
- `useHtml`: Boolean flag to send HTML email
**Features**:
- HTML email support with plain text fallback
- Custom subject lines
- Rich HTML content
- Automatic PDF attachment
- BCC to business email
- Comprehensive error handling
## Usage Examples
### Basic Usage
```tsx
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
// Replace existing send buttons
<EnhancedSendInvoiceButton
invoiceId={invoice.id}
className="w-full"
showResend={invoice.status === "sent"}
/>
```
### Custom Dialog
```tsx
import { SendEmailDialog } from "~/components/forms/send-email-dialog";
<SendEmailDialog
invoiceId={invoice.id}
invoice={invoiceData}
trigger={<Button>Send Custom Email</Button>}
onEmailSent={() => console.log("Email sent!")}
/>
```
### Standalone Components
```tsx
import { EmailComposer } from "~/components/forms/email-composer";
import { EmailPreview } from "~/components/forms/email-preview";
// Use individual components for custom implementations
<EmailComposer
subject={subject}
onSubjectChange={setSubject}
content={content}
onContentChange={setContent}
fromEmail="you@business.com"
toEmail="client@company.com"
/>
<EmailPreview
subject={subject}
content={content}
fromEmail="you@business.com"
toEmail="client@company.com"
invoice={invoiceData}
/>
```
## Technical Details
### Dependencies
- **@tiptap/react**: Rich text editor framework
- **@tiptap/starter-kit**: Basic editor functionality
- **@tiptap/extension-text-style**: Text styling support
- **@tiptap/extension-color**: Color picker support
- **@tiptap/extension-text-align**: Text alignment options
### Email Templates
The system generates professional HTML email templates with:
- Responsive design
- Brand colors (green theme)
- Invoice summary cards
- Proper typography
- Attachment indicators
- Footer branding
### Error Handling
Comprehensive error handling for:
- Invalid email addresses
- Missing client information
- Resend API issues
- Network connectivity problems
- Domain verification issues
- Rate limiting
## Usage in Application
The enhanced email functionality is integrated throughout the application:
- Invoice view pages with enhanced send buttons
- Full-page email composition interface
- Professional email templates with invoice integration
- Comprehensive preview and confirmation workflow
## Migration Guide
### From Basic Send Button
Replace existing `SendInvoiceButton` components with `EnhancedSendInvoiceButton`:
```tsx
// Before
import { SendInvoiceButton } from "../_components/send-invoice-button";
<SendInvoiceButton invoiceId={invoice.id} />
// After
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
<EnhancedSendInvoiceButton invoiceId={invoice.id} />
```
### API Compatibility
The enhanced email API is backward compatible with existing implementations. New features are opt-in through additional parameters.
## Security Considerations
- **Input Sanitization**: All user input is validated and sanitized
- **Email Validation**: Comprehensive email format validation
- **Rate Limiting**: Built-in protection against spam
- **Domain Verification**: Resend domain verification required
- **Authentication**: All email operations require valid authentication
## Performance
- **SSR Optimization**: Proper server-side rendering with hydration safeguards
- **Efficient Loading**: Content initializes immediately without requiring user interaction
- **Optimized Rendering**: Efficient React component updates with proper state management
- **Caching**: Proper query caching for invoice data
- **Error Boundaries**: Graceful error handling without crashes
- **Responsive Design**: Optimized layouts for all screen sizes with text overflow prevention
## Navigation
### Send Email Page
Access the email interface by clicking "Send Invoice" on any invoice:
- `/dashboard/invoices/[id]/send` - Full-page email composition
- Two-tab interface: Compose ↔ Preview
- Send action available from sidebar and floating action bar
- Fully responsive design with proper text wrapping and overflow handling
- Professional layout with sidebar containing:
- Invoice summary (number, client, date, status)
- Email details (from, to, subject, attachment info)
- Context-aware action buttons
- Auto-filled message with proper HTML formatting and paragraph spacing
- Immediate content loading without requiring tab navigation
## Fixes and Improvements
Recent fixes and enhancements:
- **SSR Compatibility**: Fixed Tiptap hydration issues for reliable server-side rendering
- **Content Loading**: Improved email content initialization for immediate display
- **Responsive Design**: Enhanced text wrapping and overflow handling for all screen sizes
- **UI/UX**: Removed confirmation tab in favor of action-based sending approach
- **Performance**: Optimized state management for faster content loading
## Future Enhancements
Planned improvements include:
- Email templates library
- Scheduling email delivery
- Email tracking and read receipts
- Bulk email sending
- Custom email signatures
- Integration with email marketing tools
## Support
For issues or questions related to the email system:
1. Check the console for error messages
2. Verify Resend API configuration
3. Ensure client email addresses are valid
4. Review domain verification status
5. Check network connectivity
## Changelog
### Version 1.0.0
- Initial release of enhanced email system
- Rich text editor integration
- Email preview functionality
- Send confirmation workflow
- HTML email support
- Professional templates
- Demo page implementation
+17 -11
View File
@@ -1,17 +1,23 @@
import { type Config } from "drizzle-kit";
import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv";
// Load .env.local if it exists
dotenv.config({ path: ".env.local" });
// Load .env if it exists (fallback)
dotenv.config({ path: ".env" });
import { env } from "~/env";
// Use a relative import; path alias "~" may not resolve in CLI context
// import { env } from "./src/env.js";
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not set");
}
export default {
schema: "./src/server/db/schema.ts",
dialect: "sqlite",
dbCredentials: env.DATABASE_AUTH_TOKEN
? {
url: env.DATABASE_URL,
token: env.DATABASE_AUTH_TOKEN,
}
: {
url: env.DATABASE_URL,
},
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,
},
tablesFilter: ["beenvoice_*"],
} satisfies Config;
+166
View File
@@ -0,0 +1,166 @@
CREATE TABLE "beenvoice_account" (
"id" text PRIMARY KEY NOT NULL,
"userId" varchar(255) NOT NULL,
"accountId" varchar(255) NOT NULL,
"providerId" varchar(255) NOT NULL,
"accessToken" text,
"refreshToken" text,
"accessTokenExpiresAt" timestamp,
"refreshTokenExpiresAt" timestamp,
"scope" varchar(255),
"idToken" text,
"password" text,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "beenvoice_business" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"nickname" varchar(255),
"email" varchar(255),
"phone" varchar(50),
"addressLine1" varchar(255),
"addressLine2" varchar(255),
"city" varchar(100),
"state" varchar(50),
"postalCode" varchar(20),
"country" varchar(100),
"website" varchar(255),
"taxId" varchar(100),
"logoUrl" varchar(500),
"isDefault" boolean DEFAULT false,
"resendApiKey" varchar(255),
"resendDomain" varchar(255),
"emailFromName" varchar(255),
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
CREATE TABLE "beenvoice_client" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"email" varchar(255),
"phone" varchar(50),
"addressLine1" varchar(255),
"addressLine2" varchar(255),
"city" varchar(100),
"state" varchar(50),
"postalCode" varchar(20),
"country" varchar(100),
"defaultHourlyRate" real,
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
CREATE TABLE "beenvoice_invoice_item" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"invoiceId" varchar(255) NOT NULL,
"date" timestamp NOT NULL,
"description" varchar(500) NOT NULL,
"hours" real NOT NULL,
"rate" real NOT NULL,
"amount" real NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE "beenvoice_invoice" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"invoiceNumber" varchar(100) NOT NULL,
"businessId" varchar(255),
"clientId" varchar(255) NOT NULL,
"issueDate" timestamp NOT NULL,
"dueDate" timestamp NOT NULL,
"status" varchar(50) DEFAULT 'draft' NOT NULL,
"totalAmount" real DEFAULT 0 NOT NULL,
"taxRate" real DEFAULT 0 NOT NULL,
"notes" varchar(1000),
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
CREATE TABLE "beenvoice_session" (
"id" text PRIMARY KEY NOT NULL,
"userId" varchar(255) NOT NULL,
"token" varchar(255) NOT NULL,
"expiresAt" timestamp NOT NULL,
"ipAddress" text,
"userAgent" text,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "beenvoice_session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "beenvoice_sso_provider" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"providerId" varchar(255) NOT NULL,
"userId" varchar(255) NOT NULL,
"redirectURI" varchar(255) DEFAULT '' NOT NULL,
"oidcConfig" text,
"samlConfig" text,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "beenvoice_sso_provider_providerId_unique" UNIQUE("providerId")
);
--> statement-breakpoint
CREATE TABLE "beenvoice_user" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"email" varchar(255) NOT NULL,
"emailVerified" boolean DEFAULT false NOT NULL,
"image" varchar(255),
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"password" varchar(255),
"resetToken" varchar(255),
"resetTokenExpiry" timestamp,
"prefersReducedMotion" boolean DEFAULT false NOT NULL,
"animationSpeedMultiplier" real DEFAULT 1 NOT NULL,
"colorTheme" varchar(50) DEFAULT 'slate' NOT NULL,
"customColor" varchar(50),
"theme" varchar(20) DEFAULT 'system' NOT NULL,
CONSTRAINT "beenvoice_user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "beenvoice_verification_token" (
"id" text PRIMARY KEY NOT NULL,
"identifier" varchar(255) NOT NULL,
"value" varchar(255) NOT NULL,
"expiresAt" timestamp NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "beenvoice_account" ADD CONSTRAINT "beenvoice_account_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_business" ADD CONSTRAINT "beenvoice_business_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_client" ADD CONSTRAINT "beenvoice_client_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_businessId_beenvoice_business_id_fk" FOREIGN KEY ("businessId") REFERENCES "public"."beenvoice_business"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_session" ADD CONSTRAINT "beenvoice_session_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_sso_provider" ADD CONSTRAINT "beenvoice_sso_provider_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "beenvoice_account" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "business_created_by_idx" ON "beenvoice_business" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "business_name_idx" ON "beenvoice_business" USING btree ("name");--> statement-breakpoint
CREATE INDEX "business_nickname_idx" ON "beenvoice_business" USING btree ("nickname");--> statement-breakpoint
CREATE INDEX "business_email_idx" ON "beenvoice_business" USING btree ("email");--> statement-breakpoint
CREATE INDEX "business_is_default_idx" ON "beenvoice_business" USING btree ("isDefault");--> statement-breakpoint
CREATE INDEX "client_created_by_idx" ON "beenvoice_client" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "client_name_idx" ON "beenvoice_client" USING btree ("name");--> statement-breakpoint
CREATE INDEX "client_email_idx" ON "beenvoice_client" USING btree ("email");--> statement-breakpoint
CREATE INDEX "invoice_item_invoice_id_idx" ON "beenvoice_invoice_item" USING btree ("invoiceId");--> statement-breakpoint
CREATE INDEX "invoice_item_date_idx" ON "beenvoice_invoice_item" USING btree ("date");--> statement-breakpoint
CREATE INDEX "invoice_item_position_idx" ON "beenvoice_invoice_item" USING btree ("position");--> statement-breakpoint
CREATE INDEX "invoice_business_id_idx" ON "beenvoice_invoice" USING btree ("businessId");--> statement-breakpoint
CREATE INDEX "invoice_client_id_idx" ON "beenvoice_invoice" USING btree ("clientId");--> statement-breakpoint
CREATE INDEX "invoice_created_by_idx" ON "beenvoice_invoice" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "invoice_number_idx" ON "beenvoice_invoice" USING btree ("invoiceNumber");--> statement-breakpoint
CREATE INDEX "invoice_status_idx" ON "beenvoice_invoice" USING btree ("status");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "beenvoice_session" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "sso_provider_user_id_idx" ON "beenvoice_sso_provider" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "verification_token_identifier_idx" ON "beenvoice_verification_token" USING btree ("identifier");
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE `beenvoice_invoice_item` ADD COLUMN `position` integer DEFAULT 0 NOT NULL;
CREATE INDEX `invoice_item_position_idx` ON `beenvoice_invoice_item` (`position`);
-125
View File
@@ -1,125 +0,0 @@
CREATE TABLE `beenvoice_account` (
`userId` text(255) NOT NULL,
`type` text(255) NOT NULL,
`provider` text(255) NOT NULL,
`providerAccountId` text(255) NOT NULL,
`refresh_token` text,
`access_token` text,
`expires_at` integer,
`token_type` text(255),
`scope` text(255),
`id_token` text,
`session_state` text(255),
PRIMARY KEY(`provider`, `providerAccountId`),
FOREIGN KEY (`userId`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `account_user_id_idx` ON `beenvoice_account` (`userId`);--> statement-breakpoint
CREATE TABLE `beenvoice_business` (
`id` text(255) PRIMARY KEY NOT NULL,
`name` text(255) NOT NULL,
`email` text(255),
`phone` text(50),
`addressLine1` text(255),
`addressLine2` text(255),
`city` text(100),
`state` text(50),
`postalCode` text(20),
`country` text(100),
`website` text(255),
`taxId` text(100),
`logoUrl` text(500),
`isDefault` integer DEFAULT false,
`createdById` text(255) NOT NULL,
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
`updatedAt` integer,
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `business_created_by_idx` ON `beenvoice_business` (`createdById`);--> statement-breakpoint
CREATE INDEX `business_name_idx` ON `beenvoice_business` (`name`);--> statement-breakpoint
CREATE INDEX `business_email_idx` ON `beenvoice_business` (`email`);--> statement-breakpoint
CREATE INDEX `business_is_default_idx` ON `beenvoice_business` (`isDefault`);--> statement-breakpoint
CREATE TABLE `beenvoice_client` (
`id` text(255) PRIMARY KEY NOT NULL,
`name` text(255) NOT NULL,
`email` text(255),
`phone` text(50),
`addressLine1` text(255),
`addressLine2` text(255),
`city` text(100),
`state` text(50),
`postalCode` text(20),
`country` text(100),
`createdById` text(255) NOT NULL,
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
`updatedAt` integer,
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `client_created_by_idx` ON `beenvoice_client` (`createdById`);--> statement-breakpoint
CREATE INDEX `client_name_idx` ON `beenvoice_client` (`name`);--> statement-breakpoint
CREATE INDEX `client_email_idx` ON `beenvoice_client` (`email`);--> statement-breakpoint
CREATE TABLE `beenvoice_invoice_item` (
`id` text(255) PRIMARY KEY NOT NULL,
`invoiceId` text(255) NOT NULL,
`date` integer NOT NULL,
`description` text(500) NOT NULL,
`hours` real NOT NULL,
`rate` real NOT NULL,
`amount` real NOT NULL,
`position` integer DEFAULT 0 NOT NULL,
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`invoiceId`) REFERENCES `beenvoice_invoice`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `invoice_item_invoice_id_idx` ON `beenvoice_invoice_item` (`invoiceId`);--> statement-breakpoint
CREATE INDEX `invoice_item_date_idx` ON `beenvoice_invoice_item` (`date`);--> statement-breakpoint
CREATE INDEX `invoice_item_position_idx` ON `beenvoice_invoice_item` (`position`);--> statement-breakpoint
CREATE TABLE `beenvoice_invoice` (
`id` text(255) PRIMARY KEY NOT NULL,
`invoiceNumber` text(100) NOT NULL,
`businessId` text(255),
`clientId` text(255) NOT NULL,
`issueDate` integer NOT NULL,
`dueDate` integer NOT NULL,
`status` text(50) DEFAULT 'draft' NOT NULL,
`totalAmount` real DEFAULT 0 NOT NULL,
`taxRate` real DEFAULT 0 NOT NULL,
`notes` text(1000),
`createdById` text(255) NOT NULL,
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
`updatedAt` integer,
FOREIGN KEY (`businessId`) REFERENCES `beenvoice_business`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`clientId`) REFERENCES `beenvoice_client`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `invoice_business_id_idx` ON `beenvoice_invoice` (`businessId`);--> statement-breakpoint
CREATE INDEX `invoice_client_id_idx` ON `beenvoice_invoice` (`clientId`);--> statement-breakpoint
CREATE INDEX `invoice_created_by_idx` ON `beenvoice_invoice` (`createdById`);--> statement-breakpoint
CREATE INDEX `invoice_number_idx` ON `beenvoice_invoice` (`invoiceNumber`);--> statement-breakpoint
CREATE INDEX `invoice_status_idx` ON `beenvoice_invoice` (`status`);--> statement-breakpoint
CREATE TABLE `beenvoice_session` (
`sessionToken` text(255) PRIMARY KEY NOT NULL,
`userId` text(255) NOT NULL,
`expires` integer NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `session_userId_idx` ON `beenvoice_session` (`userId`);--> statement-breakpoint
CREATE TABLE `beenvoice_user` (
`id` text(255) PRIMARY KEY NOT NULL,
`name` text(255),
`email` text(255) NOT NULL,
`password` text(255),
`emailVerified` integer DEFAULT (unixepoch()),
`image` text(255)
);
--> statement-breakpoint
CREATE TABLE `beenvoice_verification_token` (
`identifier` text(255) NOT NULL,
`token` text(255) NOT NULL,
`expires` integer NOT NULL,
PRIMARY KEY(`identifier`, `token`)
);
+43
View File
@@ -0,0 +1,43 @@
CREATE TABLE "beenvoice_expense" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"businessId" varchar(255),
"clientId" varchar(255),
"invoiceId" varchar(255),
"date" timestamp NOT NULL,
"description" varchar(500) NOT NULL,
"amount" real NOT NULL,
"currency" varchar(3) DEFAULT 'USD' NOT NULL,
"category" varchar(100),
"billable" boolean DEFAULT false NOT NULL,
"reimbursable" boolean DEFAULT false NOT NULL,
"notes" varchar(500),
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
CREATE TABLE "beenvoice_invoice_template" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"type" varchar(50) DEFAULT 'notes' NOT NULL,
"content" text NOT NULL,
"isDefault" boolean DEFAULT false NOT NULL,
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
ALTER TABLE "beenvoice_client" ADD COLUMN "currency" varchar(3) DEFAULT 'USD' NOT NULL;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD COLUMN "currency" varchar(3) DEFAULT 'USD' NOT NULL;--> statement-breakpoint
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_businessId_beenvoice_business_id_fk" FOREIGN KEY ("businessId") REFERENCES "public"."beenvoice_business"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_template" ADD CONSTRAINT "beenvoice_invoice_template_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "expense_created_by_idx" ON "beenvoice_expense" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "expense_client_id_idx" ON "beenvoice_expense" USING btree ("clientId");--> statement-breakpoint
CREATE INDEX "expense_invoice_id_idx" ON "beenvoice_expense" USING btree ("invoiceId");--> statement-breakpoint
CREATE INDEX "expense_date_idx" ON "beenvoice_expense" USING btree ("date");--> statement-breakpoint
CREATE INDEX "expense_billable_idx" ON "beenvoice_expense" USING btree ("billable");--> statement-breakpoint
CREATE INDEX "invoice_template_created_by_idx" ON "beenvoice_invoice_template" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "invoice_template_type_idx" ON "beenvoice_invoice_template" USING btree ("type");
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE `beenvoice_invoice` ADD COLUMN `taxRate` real NOT NULL DEFAULT 0;
UPDATE `beenvoice_invoice` SET `taxRate` = 0 WHERE `taxRate` IS NULL;
+1
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;
-29
View File
@@ -1,29 +0,0 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_beenvoice_invoice` (
`id` text(255) PRIMARY KEY NOT NULL,
`invoiceNumber` text(100) NOT NULL,
`businessId` text(255),
`clientId` text(255) NOT NULL,
`issueDate` integer NOT NULL,
`dueDate` integer NOT NULL,
`status` text(50) DEFAULT 'draft' NOT NULL,
`totalAmount` real DEFAULT 0 NOT NULL,
`taxRate` real DEFAULT 0 NOT NULL,
`notes` text(1000),
`createdById` text(255) NOT NULL,
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
`updatedAt` integer,
FOREIGN KEY (`businessId`) REFERENCES `beenvoice_business`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`clientId`) REFERENCES `beenvoice_client`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_beenvoice_invoice`("id", "invoiceNumber", "businessId", "clientId", "issueDate", "dueDate", "status", "totalAmount", "taxRate", "notes", "createdById", "createdAt", "updatedAt") SELECT "id", "invoiceNumber", "businessId", "clientId", "issueDate", "dueDate", "status", "totalAmount", "taxRate", "notes", "createdById", "createdAt", "updatedAt" FROM `beenvoice_invoice`;--> statement-breakpoint
DROP TABLE `beenvoice_invoice`;--> statement-breakpoint
ALTER TABLE `__new_beenvoice_invoice` RENAME TO `beenvoice_invoice`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `invoice_business_id_idx` ON `beenvoice_invoice` (`businessId`);--> statement-breakpoint
CREATE INDEX `invoice_client_id_idx` ON `beenvoice_invoice` (`clientId`);--> statement-breakpoint
CREATE INDEX `invoice_created_by_idx` ON `beenvoice_invoice` (`createdById`);--> statement-breakpoint
CREATE INDEX `invoice_number_idx` ON `beenvoice_invoice` (`invoiceNumber`);--> statement-breakpoint
CREATE INDEX `invoice_status_idx` ON `beenvoice_invoice` (`status`);
@@ -0,0 +1,11 @@
ALTER TABLE "beenvoice_user"
ADD COLUMN "bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_user"
ADD COLUMN "headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_user"
ADD COLUMN "radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_user"
ADD COLUMN "sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL;
@@ -0,0 +1,59 @@
ALTER TABLE "beenvoice_user"
ADD COLUMN "role" varchar(20) DEFAULT 'user' NOT NULL;
--> statement-breakpoint
UPDATE "beenvoice_user"
SET "role" = 'admin'
WHERE "id" = (
SELECT "id"
FROM "beenvoice_user"
ORDER BY "createdAt" ASC
LIMIT 1
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "beenvoice_platform_setting" (
"id" varchar(50) PRIMARY KEY DEFAULT 'global' NOT NULL,
"brandName" varchar(100) DEFAULT 'beenvoice' NOT NULL,
"brandTagline" varchar(255) DEFAULT 'Simple and efficient invoicing for freelancers and small businesses' NOT NULL,
"brandLogoText" varchar(100) DEFAULT 'beenvoice' NOT NULL,
"brandIcon" varchar(20) DEFAULT '$' NOT NULL,
"colorTheme" varchar(50) DEFAULT 'slate' NOT NULL,
"customColor" varchar(50),
"theme" varchar(20) DEFAULT 'system' NOT NULL,
"interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL,
"bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
"headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
"radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL,
"sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
INSERT INTO "beenvoice_platform_setting" (
"id",
"brandName",
"brandTagline",
"brandLogoText",
"brandIcon",
"colorTheme",
"customColor",
"theme",
"interfaceTheme",
"bodyFontPreference",
"headingFontPreference",
"radiusPreference",
"sidebarStyle"
) VALUES (
'global',
'beenvoice',
'Simple and efficient invoicing for freelancers and small businesses',
'beenvoice',
'$',
'slate',
NULL,
'system',
'beenvoice',
'brand',
'brand',
'xl',
'floating'
) ON CONFLICT ("id") DO NOTHING;
+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);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+53 -4
View File
@@ -1,12 +1,61 @@
{
"version": "7",
"dialect": "sqlite",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1752275489999,
"tag": "0000_unique_loa",
"version": "7",
"when": 1775354242672,
"tag": "0000_glossy_magneto",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1775356013998,
"tag": "0001_supreme_the_enforcers",
"breakpoints": true
},
{
"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 -6
View File
@@ -1,17 +1,13 @@
import { FlatCompat } from "@eslint/eslintrc";
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import tseslint from "typescript-eslint";
// @ts-ignore -- no types for this plugin
import drizzle from "eslint-plugin-drizzle";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
export default tseslint.config(
{
ignores: [".next"],
},
...compat.extends("next/core-web-vitals"),
...nextCoreWebVitals,
{
files: ["**/*.ts", "**/*.tsx"],
plugins: {
+2 -3
View File
@@ -6,9 +6,8 @@ import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {
eslint: {
ignoreDuringBuilds: true,
},
output: "standalone",
serverExternalPackages: ["pg"],
};
export default config;
-8182
View File
File diff suppressed because it is too large Load Diff
+73 -64
View File
@@ -5,105 +5,114 @@
"type": "module",
"scripts": {
"build": "next build",
"check": "next lint && tsc --noEmit",
"check": "eslint . && tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:migrate": "bun drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:push-local-to-live": "node scripts/migrate-to-turso.js",
"db:push-simple": "node scripts/migrate-simple.js",
"db:push-direct": "node scripts/migrate-direct.js",
"db:export-data": "node scripts/export-data.js",
"db:import-data": "node scripts/import-data-final.js",
"db:clone": "./scripts/clone-local.sh",
"docker:up": "colima start && docker compose up -d",
"docker: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",
"dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint",
"lint:fix": "next lint --fix",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"preview": "next build && next start",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@auth/drizzle-adapter": "^1.7.2",
"@better-auth/sso": "^1.4.12",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@libsql/client": "^0.14.0",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@react-pdf/renderer": "^4.3.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-pdf/renderer": "^4.3.1",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-table": "^8.21.3",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/file-saver": "^2.0.7",
"bcryptjs": "^3.0.2",
"chrono-node": "^2.8.3",
"@tiptap/extension-color": "^3.13.0",
"@tiptap/extension-list-item": "^3.13.0",
"@tiptap/extension-text-align": "^3.13.0",
"@tiptap/extension-text-style": "^3.13.0",
"@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0",
"@trpc/client": "^11.7.2",
"@trpc/react-query": "^11.7.2",
"@trpc/server": "^11.7.2",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.12",
"chrono-node": "^2.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.41.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"file-saver": "^2.0.5",
"lucide": "^0.525.0",
"framer-motion": "^12.23.26",
"lucide-react": "^0.525.0",
"next": "^15.4.1",
"next-auth": "5.0.0-beta.25",
"react": "^19.0.0",
"react-day-picker": "^9.8.0",
"react-dom": "^19.0.0",
"next": "^16.2.2",
"pg": "8.13.1",
"react": "^19.2.4",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.4",
"react-dropzone": "^14.3.8",
"recharts": "^3.5.1",
"resend": "^4.8.0",
"server-only": "^0.0.1",
"sonner": "^2.0.6",
"superjson": "^2.2.1",
"tailwind-merge": "^3.3.1",
"turso": "^0.1.0",
"zod": "^3.24.2"
"sonner": "^2.0.7",
"superjson": "^2.2.6",
"tailwind-merge": "^3.4.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.15",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20.14.10",
"@tailwindcss/postcss": "^4.1.18",
"@types/bcryptjs": "^2.4.6",
"@types/file-saver": "^2.0.7",
"@types/node": "^20.19.26",
"@types/pg": "^8.16.0",
"@types/raf": "^3.4.3",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"better-sqlite3": "^12.2.0",
"drizzle-kit": "^0.30.5",
"eslint": "^9.23.0",
"eslint-config-next": "^15.2.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"baseline-browser-mapping": "^2.9.6",
"drizzle-kit": "^0.30.6",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.10",
"eslint-plugin-drizzle": "^0.2.3",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.15",
"postcss": "^8.5.6",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.5",
"typescript": "^5.8.2",
"typescript-eslint": "^8.27.0"
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.49.0"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
},
"trustedDependencies": [
"@tailwindcss/oxide",
"better-sqlite3",
"core-js",
"esbuild",
"sharp",
+3 -1
View File
@@ -1,5 +1,7 @@
export default {
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+3 -1
View File
@@ -1,4 +1,6 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
export default {
const config = {
plugins: ["prettier-plugin-tailwindcss"],
};
export default config;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 56 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+71
View File
@@ -0,0 +1,71 @@
#!/bin/bash
# Function to read a variable from a specific env file
read_env_var() {
local file="$1"
local var="$2"
if [ -f "$file" ]; then
grep "^$var=" "$file" | cut -d '=' -f2- | tr -d '"' | tr -d "'"
fi
}
# 1. Get Production URL
# Priority: Argument > .env.production > .env
PROD_DB_URL="$1"
if [ -z "$PROD_DB_URL" ]; then
echo "Checking .env.production for DATABASE_URL..."
PROD_DB_URL=$(read_env_var ".env.production" "DATABASE_URL")
fi
if [ -z "$PROD_DB_URL" ]; then
echo "Checking .env for PROD_DATABASE_URL..."
PROD_DB_URL=$(read_env_var ".env" "PROD_DATABASE_URL")
fi
if [ -z "$PROD_DB_URL" ]; then
echo "Error: Could not find production database URL."
echo "Please provide it as an argument, or set DATABASE_URL in .env.production, or PROD_DATABASE_URL in .env"
echo "Usage: $0 <PROD_DATABASE_URL>"
exit 1
fi
# 2. Get Target URL
# Priority: .env.local > .env
TARGET_DB_URL=$(read_env_var ".env.local" "DATABASE_URL")
if [ -z "$TARGET_DB_URL" ]; then TARGET_DB_URL=$(read_env_var ".env" "DATABASE_URL"); fi
if [ -z "$TARGET_DB_URL" ]; then
echo "Error: Could not find target DATABASE_URL in .env.local or .env"
exit 1
fi
echo "Configuration:"
echo " Source: $PROD_DB_URL"
echo " Target: $TARGET_DB_URL"
echo
echo "⚠️ WARNING: This will OVERWRITE the target database at the above URL."
echo "This is a one-time migration script."
read -p "Are you sure you want to continue? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 1
fi
echo "Cloning database..."
# Use local pg_dump and psql directly
# This assumes pg_dump and psql are installed on the host machine
pg_dump "$PROD_DB_URL" \
--clean --if-exists \
--no-owner --no-privileges \
--format=plain \
| psql "$TARGET_DB_URL"
if [ $? -eq 0 ]; then
echo "✅ Database cloned successfully!"
else
echo "❌ Database clone failed."
exit 1
fi
-109
View File
@@ -1,109 +0,0 @@
import { execSync } from "child_process";
import { readFileSync, writeFileSync, existsSync } from "fs";
async function exportData() {
console.log("📦 Exporting data from local SQLite database...\n");
try {
// Check if local database exists
if (!existsSync("./db.sqlite")) {
console.error("❌ Local database db.sqlite not found!");
process.exit(1);
}
console.log("✅ Found local database");
// Create SQL dump
console.log("🔄 Creating SQL dump...");
const dumpPath = "./data_export.sql";
try {
execSync(`sqlite3 db.sqlite ".dump" > ${dumpPath}`, { stdio: "inherit" });
console.log("✅ SQL dump created");
} catch (error) {
console.error(
"❌ Failed to create SQL dump. Make sure sqlite3 is installed.",
);
process.exit(1);
}
// Read and filter the dump file
console.log("🔍 Extracting data statements...");
const dumpContent = readFileSync(dumpPath, "utf8");
const lines = dumpContent.split("\n");
// Extract only INSERT statements for beenvoice tables
const dataStatements = [];
// Add header comment
dataStatements.push("-- beenvoice Data Export");
dataStatements.push("-- Generated: " + new Date().toISOString());
dataStatements.push(
"-- Run these INSERT statements in your Turso database",
);
dataStatements.push("");
// Extract table data in proper order (for foreign keys)
const tableOrder = [
"beenvoice_user",
"beenvoice_account",
"beenvoice_session",
"beenvoice_client",
"beenvoice_business",
"beenvoice_invoice",
"beenvoice_invoice_item",
];
for (const tableName of tableOrder) {
const tableStatements = lines.filter(
(line) =>
line.startsWith(`INSERT INTO ${tableName}`) ||
line.startsWith(`INSERT INTO \`${tableName}\``),
);
if (tableStatements.length > 0) {
dataStatements.push(
`-- Data for ${tableName} (${tableStatements.length} records)`,
);
dataStatements.push(...tableStatements);
dataStatements.push("");
}
}
// Write clean export file
const exportContent = dataStatements.join("\n");
writeFileSync("./beenvoice_data_export.sql", exportContent);
// Count total records
const totalInserts = dataStatements.filter((line) =>
line.startsWith("INSERT"),
).length;
console.log(`\n🎉 Data export completed!`);
console.log(` 📄 File: beenvoice_data_export.sql`);
console.log(` 📊 Total records: ${totalInserts}`);
console.log(`\n📋 Manual steps to complete migration:`);
console.log(` 1. Run: bun run db:push (to create tables in Turso)`);
console.log(
` 2. Copy the INSERT statements from beenvoice_data_export.sql`,
);
console.log(` 3. Run them in your Turso database`);
console.log(
`\n💡 Or use turso db shell beenvoice < beenvoice_data_export.sql`,
);
// Clean up temp file
try {
execSync(`rm ${dumpPath}`);
} catch (e) {
// Cleanup failed, that's okay
}
} catch (error) {
console.error(
"\n❌ Export failed:",
error instanceof Error ? error.message : String(error),
);
process.exit(1);
}
}
exportData().catch(console.error);
-184
View File
@@ -1,184 +0,0 @@
import { createClient } from "@libsql/client";
import { readFileSync, existsSync } from "fs";
// Read .env file directly
function loadEnvVars() {
const envPath = "./.env";
if (!existsSync(envPath)) {
console.error("❌ .env file not found!");
process.exit(1);
}
const envContent = readFileSync(envPath, "utf8");
const envVars = /** @type {Record<string, string>} */ ({});
envContent.split("\n").forEach((line) => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
const [key, ...valueParts] = trimmed.split("=");
if (key) {
const value = valueParts.join("=").replace(/^["']|["']$/g, "");
envVars[key.trim()] = value.trim();
}
}
});
return envVars;
}
async function importData() {
console.log("🚀 Importing data to live Turso database...\n");
try {
// Load environment variables
console.log("🔧 Loading environment variables...");
const envVars = loadEnvVars();
if (!envVars.DATABASE_URL || !envVars.DATABASE_AUTH_TOKEN) {
console.error(
"❌ Missing DATABASE_URL or DATABASE_AUTH_TOKEN in .env file",
);
console.log(
"💡 Make sure your .env file contains your Turso credentials",
);
process.exit(1);
}
console.log("✅ Environment variables loaded");
// Check if export file exists
const exportFile = "./beenvoice_data_export.sql";
if (!existsSync(exportFile)) {
console.error("❌ Export file not found!");
console.log(
"💡 Run 'bun run db:export-data' first to create the export file",
);
process.exit(1);
}
console.log("✅ Found data export file");
// Connect to Turso
console.log("🔗 Connecting to live Turso database...");
const tursoClient = createClient({
url: envVars.DATABASE_URL,
authToken: envVars.DATABASE_AUTH_TOKEN,
});
console.log("✅ Connected to Turso");
// Read the export file
console.log("📖 Reading export file...");
const sqlContent = readFileSync(exportFile, "utf8");
const lines = sqlContent.split("\n");
// Filter for INSERT statements only
const insertStatements = lines.filter((line) =>
line.trim().startsWith("INSERT INTO beenvoice_"),
);
console.log(`📊 Found ${insertStatements.length} data records to import`);
if (insertStatements.length === 0) {
console.log("⚠️ No INSERT statements found in export file");
process.exit(0);
}
// Clear existing data first (in reverse foreign key order)
console.log("🗑️ Clearing existing data...");
const tablesToClear = [
"beenvoice_invoice_item",
"beenvoice_invoice",
"beenvoice_business",
"beenvoice_client",
"beenvoice_session",
"beenvoice_account",
"beenvoice_user",
];
for (const table of tablesToClear) {
try {
await tursoClient.execute(`DELETE FROM ${table}`);
console.log(` ✅ Cleared ${table}`);
} catch (error) {
console.log(
` ⏭️ Skipped ${table} (${error instanceof Error ? error.message : String(error)})`,
);
}
}
// Execute INSERT statements
console.log("📤 Importing data...");
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < insertStatements.length; i++) {
const statementLine = insertStatements[i];
if (!statementLine) continue;
const statement = statementLine.trim();
try {
await tursoClient.execute(statement);
successCount++;
// Show progress every 50 records
if (successCount % 50 === 0) {
console.log(
` 📝 Imported ${successCount}/${insertStatements.length} records...`,
);
}
} catch (error) {
errorCount++;
if (errorCount <= 5) {
// Only show first 5 errors
console.error(
` ❌ Error importing record ${i + 1}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
// Verify the import
console.log("\n🔍 Verifying import...");
const tables = [
"beenvoice_user",
"beenvoice_client",
"beenvoice_business",
"beenvoice_invoice",
"beenvoice_invoice_item",
];
let totalRecords = 0;
for (const table of tables) {
try {
const result = await tursoClient.execute(
`SELECT COUNT(*) as count FROM ${table}`,
);
const count = parseInt(String(result.rows[0]?.count || 0));
if (count > 0) {
console.log(` 📊 ${table}: ${count} records`);
totalRecords += count;
}
} catch (error) {
console.log(` ⏭️ ${table}: not accessible`);
}
}
console.log(`\n🎉 Import completed!`);
console.log(`${successCount} records imported successfully`);
if (errorCount > 0) {
console.log(` ⚠️ ${errorCount} records had errors`);
}
console.log(` 📊 ${totalRecords} total records now in live database`);
console.log(`\n💡 Your local data is now live on Turso!`);
console.log(`💡 Your Vercel deployment will use this data.`);
} catch (error) {
console.error(
"\n❌ Import failed:",
error instanceof Error ? error.message : String(error),
);
process.exit(1);
} finally {
console.log("🔌 Done!");
}
}
importData().catch(console.error);
-252
View File
@@ -1,252 +0,0 @@
import { createClient } from "@libsql/client";
import { execSync } from "child_process";
import { readFileSync, writeFileSync, unlinkSync, existsSync } from "fs";
// Read .env file directly
function loadEnvVars() {
const envPath = "./.env";
if (!existsSync(envPath)) {
console.error("❌ .env file not found!");
process.exit(1);
}
const envContent = readFileSync(envPath, "utf8");
const envVars = /** @type {Record<string, string>} */ ({});
envContent.split("\n").forEach((line) => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
const [key, ...valueParts] = trimmed.split("=");
if (key) {
const value = valueParts.join("=").replace(/^["']|["']$/g, ""); // Remove quotes
envVars[key.trim()] = value.trim();
}
}
});
return envVars;
}
async function migrateToTurso() {
console.log("🚀 Pushing local SQLite data to live Turso database...\n");
try {
// Load environment variables
console.log("🔧 Loading environment variables...");
const envVars = loadEnvVars();
if (!envVars.DATABASE_URL || !envVars.DATABASE_AUTH_TOKEN) {
console.error(
"❌ Missing DATABASE_URL or DATABASE_AUTH_TOKEN in .env file",
);
console.log("💡 Make sure your .env file contains:");
console.log(" DATABASE_URL=libsql://your-database-url");
console.log(" DATABASE_AUTH_TOKEN=your-auth-token");
process.exit(1);
}
console.log("✅ Environment variables loaded");
// Check if local database exists
console.log("📁 Checking local database...");
if (!existsSync("./db.sqlite")) {
console.error("❌ Local database db.sqlite not found!");
process.exit(1);
}
console.log("✅ Found local database");
// Create SQL dump of local database
console.log("📦 Creating SQL dump from local database...");
const dumpPath = "./temp_dump.sql";
try {
execSync(`sqlite3 db.sqlite ".dump" > ${dumpPath}`, { stdio: "inherit" });
console.log("✅ SQL dump created");
} catch (error) {
console.error(
"❌ Failed to create SQL dump. Make sure sqlite3 is installed.",
);
process.exit(1);
}
// Read and filter the dump file
console.log("🔍 Processing SQL dump...");
const dumpContent = readFileSync(dumpPath, "utf8");
// Split into lines and filter for beenvoice tables
const lines = dumpContent.split("\n");
const filteredLines = [];
let inBeenvoiceTable = false;
for (const line of lines) {
// Skip PRAGMA and TRANSACTION statements
if (
line.startsWith("PRAGMA") ||
line.startsWith("BEGIN TRANSACTION") ||
line.startsWith("COMMIT")
) {
continue;
}
// Check if we're starting a beenvoice table
if (
line.startsWith("CREATE TABLE `beenvoice_") ||
line.startsWith("CREATE TABLE beenvoice_")
) {
inBeenvoiceTable = true;
filteredLines.push(line);
continue;
}
// Check if we're inserting into a beenvoice table
if (
line.startsWith("INSERT INTO beenvoice_") ||
line.startsWith("INSERT INTO `beenvoice_")
) {
filteredLines.push(line);
continue;
}
// If we were in a beenvoice table and hit another CREATE TABLE, we're done with that table
if (
inBeenvoiceTable &&
line.startsWith("CREATE TABLE") &&
!line.includes("beenvoice_")
) {
inBeenvoiceTable = false;
}
// If we're in a beenvoice table, include the line
if (inBeenvoiceTable) {
filteredLines.push(line);
}
}
console.log(`✅ Filtered ${filteredLines.length} SQL statements`);
// Connect to Turso
console.log("🔗 Connecting to live Turso database...");
const tursoClient = createClient({
url: envVars.DATABASE_URL,
authToken: envVars.DATABASE_AUTH_TOKEN,
});
console.log("✅ Connected to Turso");
// Clear existing data from beenvoice tables (in reverse order for foreign keys)
console.log("🗑️ Clearing existing data...");
const tablesToClear = [
"beenvoice_invoice_item",
"beenvoice_invoice",
"beenvoice_client",
"beenvoice_business",
"beenvoice_session",
"beenvoice_account",
"beenvoice_user",
];
for (const table of tablesToClear) {
try {
await tursoClient.execute(`DELETE FROM ${table}`);
console.log(` ✅ Cleared ${table}`);
} catch (error) {
console.log(
` ⏭️ Skipped ${table} (doesn't exist or error: ${error instanceof Error ? error.message : String(error)})`,
);
}
}
// Execute the filtered SQL statements
console.log("📤 Pushing data to Turso...");
let successCount = 0;
let errorCount = 0;
let insertCount = 0;
for (const line of filteredLines) {
const trimmed = line.trim();
if (!trimmed || trimmed === "") continue;
try {
await tursoClient.execute(trimmed);
successCount++;
// Count and show progress for inserts
if (trimmed.startsWith("INSERT")) {
insertCount++;
if (insertCount % 20 === 0) {
console.log(` 📝 Inserted ${insertCount} records...`);
}
}
} catch (error) {
errorCount++;
if (trimmed.startsWith("CREATE TABLE")) {
console.log(
` ⚠️ Table already exists: ${trimmed.substring(0, 50)}...`,
);
} else {
console.error(
` ❌ Error executing: ${trimmed.substring(0, 50)}...`,
);
console.error(
` Error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
// Verify the migration
console.log("\n🔍 Verifying migration...");
const tables = [
"beenvoice_user",
"beenvoice_client",
"beenvoice_business",
"beenvoice_invoice",
"beenvoice_invoice_item",
];
let totalRecords = 0;
for (const table of tables) {
try {
const result = await tursoClient.execute(
`SELECT COUNT(*) as count FROM ${table}`,
);
const count = String(result.rows[0]?.count || 0);
console.log(` 📊 ${table}: ${count} records`);
totalRecords += parseInt(count);
} catch (error) {
console.log(` ⏭️ ${table}: table doesn't exist`);
}
}
console.log(`\n🎉 Migration completed successfully!`);
console.log(`${successCount} SQL statements executed`);
console.log(` 📝 ${insertCount} data records inserted`);
console.log(` 📊 ${totalRecords} total records in live database`);
if (errorCount > 0) {
console.log(
` ⚠️ ${errorCount} statements had errors (likely table creation conflicts)`,
);
}
console.log(`\n💡 Your local data is now live on Turso!`);
console.log(`💡 Your Vercel deployment will use this data.`);
} catch (error) {
console.error(
"\n❌ Migration failed:",
error instanceof Error ? error.message : String(error),
);
console.error("Full error:", error);
process.exit(1);
} finally {
// Cleanup
try {
if (existsSync("./temp_dump.sql")) {
unlinkSync("./temp_dump.sql");
console.log("🧹 Cleaned up temporary files");
}
} catch (e) {
// File cleanup failed, that's okay
}
console.log("🔌 Done!");
}
}
migrateToTurso().catch(console.error);
-211
View File
@@ -1,211 +0,0 @@
import { createClient } from "@libsql/client";
import { execSync } from "child_process";
import { readFileSync, writeFileSync, unlinkSync, existsSync } from "fs";
import { env } from "../src/env.js";
async function migrateToTurso() {
console.log("🚀 Pushing local SQLite data to live Turso database...\n");
try {
// Check if local database exists
console.log("📁 Checking local database...");
const dbExists = existsSync("./db.sqlite");
if (!dbExists) {
console.error("❌ Local database db.sqlite not found!");
process.exit(1);
}
console.log("✅ Found local database");
// Create SQL dump of local database
console.log("📦 Creating SQL dump from local database...");
const dumpPath = "./temp_dump.sql";
try {
execSync(`sqlite3 db.sqlite ".dump" > ${dumpPath}`, { stdio: "inherit" });
console.log("✅ SQL dump created");
} catch (error) {
console.error(
"❌ Failed to create SQL dump. Make sure sqlite3 is installed.",
);
process.exit(1);
}
// Read and filter the dump file
console.log("🔍 Processing SQL dump...");
const dumpContent = readFileSync(dumpPath, "utf8");
// Split into lines and filter for beenvoice tables
const lines = dumpContent.split("\n");
const filteredLines = [];
let inBeenvoiceTable = false;
for (const line of lines) {
// Skip PRAGMA and TRANSACTION statements
if (
line.startsWith("PRAGMA") ||
line.startsWith("BEGIN TRANSACTION") ||
line.startsWith("COMMIT")
) {
continue;
}
// Check if we're starting a beenvoice table
if (
line.startsWith("CREATE TABLE `beenvoice_") ||
line.startsWith("CREATE TABLE beenvoice_")
) {
inBeenvoiceTable = true;
filteredLines.push(line);
continue;
}
// Check if we're inserting into a beenvoice table
if (
line.startsWith("INSERT INTO beenvoice_") ||
line.startsWith("INSERT INTO `beenvoice_")
) {
filteredLines.push(line);
continue;
}
// If we were in a beenvoice table and hit another CREATE TABLE, we're done with that table
if (
inBeenvoiceTable &&
line.startsWith("CREATE TABLE") &&
!line.includes("beenvoice_")
) {
inBeenvoiceTable = false;
}
// If we're in a beenvoice table, include the line
if (inBeenvoiceTable) {
filteredLines.push(line);
}
}
console.log(`✅ Filtered ${filteredLines.length} SQL statements`);
// Connect to Turso
console.log("🔗 Connecting to live Turso database...");
if (!env.DATABASE_URL || !env.DATABASE_AUTH_TOKEN) {
console.error("❌ Missing DATABASE_URL or DATABASE_AUTH_TOKEN");
console.log("💡 Make sure your .env file has the Turso credentials");
process.exit(1);
}
const tursoClient = createClient({
url: env.DATABASE_URL,
authToken: env.DATABASE_AUTH_TOKEN,
});
console.log("✅ Connected to Turso");
// Clear existing data from beenvoice tables
console.log("🗑️ Clearing existing data...");
const tablesToClear = [
"beenvoice_invoice_item",
"beenvoice_invoice",
"beenvoice_client",
"beenvoice_business",
"beenvoice_session",
"beenvoice_account",
"beenvoice_user",
];
for (const table of tablesToClear) {
try {
await tursoClient.execute(`DELETE FROM ${table}`);
console.log(` ✅ Cleared ${table}`);
} catch (error) {
// Table might not exist, that's okay
console.log(` ⏭️ Skipped ${table} (doesn't exist)`);
}
}
// Execute the filtered SQL statements
console.log("📤 Pushing data to Turso...");
let successCount = 0;
let errorCount = 0;
for (const line of filteredLines) {
const trimmed = line.trim();
if (!trimmed || trimmed === "") continue;
try {
await tursoClient.execute(trimmed);
successCount++;
// Show progress for inserts
if (trimmed.startsWith("INSERT")) {
const match = trimmed.match(/INSERT INTO (\w+)/);
if (match && successCount % 10 === 0) {
console.log(` 📝 Inserted ${successCount} records...`);
}
}
} catch (error) {
errorCount++;
if (trimmed.startsWith("CREATE TABLE")) {
console.log(
` ⚠️ Table already exists: ${trimmed.substring(0, 50)}...`,
);
} else {
console.error(
` ❌ Error executing: ${trimmed.substring(0, 50)}...`,
);
console.error(
` Error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
// Verify the migration
console.log("\n🔍 Verifying migration...");
const tables = [
"beenvoice_user",
"beenvoice_client",
"beenvoice_invoice",
"beenvoice_invoice_item",
];
for (const table of tables) {
try {
const result = await tursoClient.execute(
`SELECT COUNT(*) as count FROM ${table}`,
);
const count = result.rows[0]?.count || 0;
console.log(` 📊 ${table}: ${count} records`);
} catch (error) {
console.log(` ⏭️ ${table}: table doesn't exist`);
}
}
console.log(`\n🎉 Migration completed!`);
console.log(`${successCount} statements executed successfully`);
if (errorCount > 0) {
console.log(
` ⚠️ ${errorCount} statements had errors (likely table creation conflicts)`,
);
}
console.log(`\n💡 Your local data is now live on Turso!`);
console.log(`💡 Your Vercel deployment will use this data.`);
} catch (error) {
console.error(
"\n❌ Migration failed:",
error instanceof Error ? error.message : String(error),
);
process.exit(1);
} finally {
// Cleanup
try {
unlinkSync("./temp_dump.sql");
console.log("🧹 Cleaned up temporary files");
} catch (e) {
// File might not exist, that's okay
}
console.log("🔌 Done!");
}
}
migrateToTurso().catch(console.error);
-92
View File
@@ -1,92 +0,0 @@
import { createClient } from "@libsql/client";
import Database from "better-sqlite3";
import { env } from "../src/env.js";
async function migrateToTurso() {
console.log("🚀 Pushing local data to live Turso database...\n");
// Connect to local SQLite database
const localDb = new Database("./db.sqlite");
console.log("✅ Connected to local database");
// Connect to live Turso database using existing env vars
const tursoClient = createClient({
url: env.DATABASE_URL,
authToken: env.DATABASE_AUTH_TOKEN,
});
console.log("✅ Connected to live Turso database");
try {
// Get all tables with data
const tables = localDb
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'beenvoice_%'",
)
.all();
console.log(`\n📋 Found ${tables.length} tables to migrate:`);
tables.forEach((table) => console.log(` - ${table.name}`));
// Migration order to handle foreign key constraints
const migrationOrder = [
"beenvoice_user",
"beenvoice_account",
"beenvoice_session",
"beenvoice_client",
"beenvoice_business",
"beenvoice_invoice",
"beenvoice_invoice_item",
];
for (const tableName of migrationOrder) {
if (!tables.find((t) => t.name === tableName)) {
console.log(`⏭️ Skipping ${tableName} (not found locally)`);
continue;
}
console.log(`\n📦 Processing ${tableName}...`);
// Get local data
const localData = localDb.prepare(`SELECT * FROM ${tableName}`).all();
console.log(` Found ${localData.length} local records`);
if (localData.length === 0) {
console.log(` ✅ No data to migrate`);
continue;
}
// Clear remote table first
await tursoClient.execute(`DELETE FROM ${tableName}`);
console.log(` 🗑️ Cleared remote table`);
// Insert all local data
for (const row of localData) {
const columns = Object.keys(row);
const values = Object.values(row);
const placeholders = columns.map(() => "?").join(", ");
const sql = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
await tursoClient.execute({
sql,
args: values,
});
}
console.log(` ✅ Pushed ${localData.length} records to live database`);
}
console.log("\n🎉 Migration completed!");
console.log("💡 Local data is now live on Turso");
console.log("💡 Your Vercel deployment will use this data");
} catch (error) {
console.error("\n❌ Migration failed:", error.message);
process.exit(1);
} finally {
localDb.close();
tursoClient.close();
console.log("\n🔌 Connections closed");
}
}
migrateToTurso().catch(console.error);
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." &>/dev/null && pwd)"
cd "${PROJECT_ROOT}"
echo "[setup-env] Project root: ${PROJECT_ROOT}"
ENV_EXAMPLE_FILE="${PROJECT_ROOT}/env.example"
ENV_FILE="${PROJECT_ROOT}/.env"
FORCE=${FORCE:-false}
if [[ ! -f "${ENV_EXAMPLE_FILE}" ]]; then
echo "[setup-env] ERROR: env.example not found at ${ENV_EXAMPLE_FILE}" >&2
exit 1
fi
if [[ -f "${ENV_FILE}" && "${FORCE}" != "true" ]]; then
echo "[setup-env] .env already exists. Set FORCE=true to overwrite. Skipping."
exit 0
fi
echo "[setup-env] Generating secrets for .env"
GEN_AUTH_SECRET=$(openssl rand -hex 32 2>/dev/null || cat /proc/sys/kernel/random/uuid)
GEN_DB_PASSWORD=$(openssl rand -hex 16 2>/dev/null || cat /proc/sys/kernel/random/uuid)
TMP_FILE=$(mktemp)
sed \
-e "s/^AUTH_SECRET=__GENERATE__/AUTH_SECRET=${GEN_AUTH_SECRET}/" \
-e "s/^POSTGRES_PASSWORD=__GENERATE__/POSTGRES_PASSWORD=${GEN_DB_PASSWORD}/" \
"${ENV_EXAMPLE_FILE}" > "${TMP_FILE}"
mv "${TMP_FILE}" "${ENV_FILE}"
echo "[setup-env] Wrote ${ENV_FILE} with generated AUTH_SECRET and POSTGRES_PASSWORD"
echo "[setup-env] You can edit ${ENV_FILE} to adjust PORT, RESEND_* and other values."
exit 0
#!/usr/bin/env bash
set -euo pipefail
# Resolve project root (directory containing this script's parent)
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." &>/dev/null && pwd)"
cd "${PROJECT_ROOT}"
echo "[setup-env] Project root: ${PROJECT_ROOT}"
ENV_EXAMPLE_FILE="${PROJECT_ROOT}/env.example"
ENV_FILE="${PROJECT_ROOT}/.env"
FORCE=${FORCE:-false}
if [[ ! -f "${ENV_EXAMPLE_FILE}" ]]; then
echo "[setup-env] ERROR: env.example not found at ${ENV_EXAMPLE_FILE}" >&2
exit 1
fi
if [[ -f "${ENV_FILE}" && "${FORCE}" != "true" ]]; then
echo "[setup-env] .env already exists. Set FORCE=true to overwrite. Skipping."
exit 0
fi
echo "[setup-env] Generating secrets for .env"
# Generate secrets
GEN_AUTH_SECRET=$(openssl rand -hex 32 2>/dev/null || cat /proc/sys/kernel/random/uuid)
GEN_DB_PASSWORD=$(openssl rand -hex 16 2>/dev/null || cat /proc/sys/kernel/random/uuid)
TMP_FILE=$(mktemp)
# Perform replacements
sed \
-e "s/^AUTH_SECRET=__GENERATE__/AUTH_SECRET=${GEN_AUTH_SECRET}/" \
-e "s/^POSTGRES_PASSWORD=__GENERATE__/POSTGRES_PASSWORD=${GEN_DB_PASSWORD}/" \
"${ENV_EXAMPLE_FILE}" > "${TMP_FILE}"
mv "${TMP_FILE}" "${ENV_FILE}"
echo "[setup-env] Wrote ${ENV_FILE} with generated AUTH_SECRET and POSTGRES_PASSWORD"
echo "[setup-env] You can edit ${ENV_FILE} to adjust PORT, RESEND_* and other values."
exit 0
+390
View File
@@ -0,0 +1,390 @@
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export default function PrivacyPolicyPage() {
return (
<div className="bg-background min-h-screen">
{/* Header */}
<div className="bg-card border-b">
<div className="container mx-auto max-w-4xl px-6 py-6">
<div className="flex items-center space-x-4">
<Link href="/auth/signin">
<Button variant="outline" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Privacy Policy</h1>
<p className="text-muted-foreground text-sm">
Last updated: {new Date().toLocaleDateString()}
</p>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="container mx-auto max-w-4xl px-6 py-8">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Introduction</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
beenvoice (&quot;we&quot;, &quot;our&quot;, or &quot;us&quot;)
is committed to protecting your privacy. This Privacy Policy
explains how we collect, use, disclose, and safeguard your
information when you use our invoicing platform and services.
</p>
<p>
Please read this Privacy Policy carefully. If you do not agree
with the terms of this Privacy Policy, please do not access or
use our Service.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Information We Collect</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<h4>Personal Information</h4>
<p>
We may collect personal information that you voluntarily provide
to us when you:
</p>
<ul>
<li>Register for an account</li>
<li>Create invoices or manage client information</li>
<li>Contact us for support</li>
<li>Subscribe to our newsletters or communications</li>
</ul>
<p>This personal information may include:</p>
<ul>
<li>Name and contact information (email, phone, address)</li>
<li>Business information and tax details</li>
<li>Client information you input into the system</li>
<li>Financial information related to your invoices</li>
<li>
Payment information (processed securely by third-party
providers)
</li>
</ul>
<h4>Automatically Collected Information</h4>
<p>
We may automatically collect certain information when you visit
our Service:
</p>
<ul>
<li>
Device information (IP address, browser type, operating
system)
</li>
<li>Usage data (pages visited, time spent, features used)</li>
<li>Log files and analytics data</li>
<li>Cookies and similar tracking technologies</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>How We Use Your Information</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>We use the information we collect to:</p>
<ul>
<li>Provide, operate, and maintain our Service</li>
<li>Process your transactions and manage your account</li>
<li>Improve and personalize your experience</li>
<li>
Communicate with you about your account and our services
</li>
<li>Send you technical notices and support messages</li>
<li>Respond to your comments, questions, and requests</li>
<li>Monitor usage and analyze trends</li>
<li>
Detect, prevent, and address technical issues and security
breaches
</li>
<li>Comply with legal obligations</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>How We Share Your Information</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
We do not sell, trade, or rent your personal information to
third parties. We may share your information in the following
circumstances:
</p>
<h4>Service Providers</h4>
<p>
We may share your information with trusted third-party service
providers who assist us in operating our Service, such as:
</p>
<ul>
<li>Cloud hosting and storage providers</li>
<li>Payment processors</li>
<li>Email service providers</li>
<li>Analytics and monitoring services</li>
</ul>
<h4>Legal Requirements</h4>
<p>
We may disclose your information if required to do so by law or
in response to:
</p>
<ul>
<li>Legal processes (subpoenas, court orders)</li>
<li>Government requests</li>
<li>Law enforcement investigations</li>
<li>Protection of our rights, property, or safety</li>
</ul>
<h4>Business Transfers</h4>
<p>
In the event of a merger, acquisition, or sale of assets, your
information may be transferred as part of that transaction.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Data Security</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
We implement appropriate technical and organizational security
measures to protect your information:
</p>
<ul>
<li>Encryption of data in transit and at rest</li>
<li>Secure access controls and authentication</li>
<li>Regular security assessments and updates</li>
<li>Employee training on data protection</li>
<li>Incident response procedures</li>
</ul>
<p>
However, no method of transmission over the internet or
electronic storage is 100% secure. While we strive to protect
your information, we cannot guarantee absolute security.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Data Retention</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
We retain your personal information only for as long as
necessary to fulfill the purposes outlined in this Privacy
Policy, unless a longer retention period is required by law.
</p>
<p>
Factors we consider when determining retention periods include:
</p>
<ul>
<li>The nature and sensitivity of the information</li>
<li>Legal and regulatory requirements</li>
<li>Business and operational needs</li>
<li>Your account status and activity</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Your Rights and Choices</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Depending on your location, you may have the following rights
regarding your personal information:
</p>
<h4>Access and Portability</h4>
<ul>
<li>Request access to your personal information</li>
<li>Receive a copy of your data in a portable format</li>
</ul>
<h4>Correction and Updates</h4>
<ul>
<li>Correct inaccurate or incomplete information</li>
<li>Update your account information at any time</li>
</ul>
<h4>Deletion</h4>
<ul>
<li>Request deletion of your personal information</li>
<li>Close your account and remove your data</li>
</ul>
<h4>Restriction and Objection</h4>
<ul>
<li>Restrict the processing of your information</li>
<li>Object to certain uses of your data</li>
</ul>
<p>
To exercise these rights, please contact us using the
information provided in the &quot;Contact Us&quot; section
below.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Cookies and Tracking Technologies</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>We use cookies and similar technologies to:</p>
<ul>
<li>Remember your preferences and settings</li>
<li>Authenticate your account</li>
<li>Analyze usage patterns and improve our Service</li>
<li>Provide personalized content and features</li>
</ul>
<p>
You can control cookies through your browser settings. However,
disabling cookies may affect the functionality of our Service.
</p>
<h4>Types of Cookies We Use</h4>
<ul>
<li>
<strong>Essential Cookies:</strong> Required for the Service
to function properly
</li>
<li>
<strong>Analytics Cookies:</strong> Help us understand how you
use our Service
</li>
<li>
<strong>Preference Cookies:</strong> Remember your settings
and preferences
</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Third-Party Links and Services</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Our Service may contain links to third-party websites or
integrate with third-party services. We are not responsible for
the privacy practices of these third parties.
</p>
<p>
We encourage you to read the privacy policies of any third-party
services you use in connection with our Service.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Children&apos;s Privacy</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Our Service is not intended for children under the age of 13. We
do not knowingly collect personal information from children
under 13.
</p>
<p>
If you are a parent or guardian and believe your child has
provided us with personal information, please contact us
immediately so we can remove such information.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>International Data Transfers</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Your information may be transferred to and processed in
countries other than your own. We ensure that such transfers
comply with applicable data protection laws.
</p>
<p>
When we transfer your information internationally, we implement
appropriate safeguards to protect your data, including:
</p>
<ul>
<li>Standard contractual clauses</li>
<li>Adequacy decisions by relevant authorities</li>
<li>Certified privacy frameworks</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Changes to This Privacy Policy</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
We may update this Privacy Policy from time to time. We will
notify you of any material changes by:
</p>
<ul>
<li>Posting the updated policy on our Service</li>
<li>Sending you an email notification</li>
<li>Displaying a prominent notice on our Service</li>
</ul>
<p>
Your continued use of our Service after any changes indicates
your acceptance of the updated Privacy Policy.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Contact Us</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
If you have questions about this Privacy Policy or our privacy
practices, please contact us at:
</p>
<ul>
<li>Email: privacy@beenvoice.com</li>
<li>Address: [Your Business Address]</li>
</ul>
<p>
We will respond to your inquiries within a reasonable timeframe
and in accordance with applicable law.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
+306
View File
@@ -0,0 +1,306 @@
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export default function TermsOfServicePage() {
return (
<div className="bg-background min-h-screen">
{/* Header */}
<div className="bg-card border-b">
<div className="container mx-auto max-w-4xl px-6 py-6">
<div className="flex items-center space-x-4">
<Link href="/auth/signin">
<Button variant="outline" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Terms of Service</h1>
<p className="text-muted-foreground text-sm">
Last updated: {new Date().toLocaleDateString()}
</p>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="container mx-auto max-w-4xl px-6 py-8">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Agreement to Terms</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
These Terms of Service (&quot;Terms&quot;) govern your use of the
beenvoice platform and services (the &quot;Service&quot;) operated by
beenvoice (&quot;us&quot;, &quot;we&quot;, or &quot;our&quot;).
</p>
<p>
By accessing or using our Service, you agree to be bound by
these Terms. If you disagree with any part of these terms, then
you may not access the Service.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Description of Service</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
beenvoice is a web-based invoicing platform that allows users
to:
</p>
<ul>
<li>Create and manage professional invoices</li>
<li>Track client information and billing details</li>
<li>Monitor payment status and financial metrics</li>
<li>Generate reports and analytics</li>
<li>Manage business profiles and settings</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>User Accounts</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
When you create an account with us, you must provide information
that is accurate, complete, and current at all times. You are
responsible for safeguarding the password and for all activities
that occur under your account.
</p>
<p>
You agree not to disclose your password to any third party. You
must notify us immediately upon becoming aware of any breach of
security or unauthorized use of your account.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Acceptable Use</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>You agree not to use the Service:</p>
<ul>
<li>
For any unlawful purpose or to solicit others to perform
unlawful acts
</li>
<li>
To violate any international, federal, provincial, or state
regulations, rules, laws, or local ordinances
</li>
<li>
To infringe upon or violate our intellectual property rights
or the intellectual property rights of others
</li>
<li>
To harass, abuse, insult, harm, defame, slander, disparage,
intimidate, or discriminate
</li>
<li>To submit false or misleading information</li>
<li>
To upload or transmit viruses or any other type of malicious
code
</li>
<li>
To spam, phish, pharm, pretext, spider, crawl, or scrape
</li>
<li>For any obscene or immoral purpose</li>
<li>
To interfere with or circumvent the security features of the
Service
</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Data and Privacy</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Your privacy is important to us. Please review our Privacy
Policy, which also governs your use of the Service, to
understand our practices.
</p>
<p>
You retain ownership of your data. We will not sell, rent, or
share your personal information with third parties without your
explicit consent, except as described in our Privacy Policy.
</p>
<p>
You are responsible for backing up your data. While we implement
regular backups, we recommend you maintain your own copies of
important information.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Payment Terms</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
Some aspects of the Service may require payment. You will be
charged according to your subscription plan. All fees are
non-refundable unless otherwise stated.
</p>
<p>
We may change our fees at any time. We will provide you with
reasonable notice of any fee changes by posting the new fees on
the Service or sending you email notification.
</p>
<p>
If you fail to pay any fees when due, we may suspend or
terminate your access to the Service until payment is made.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Intellectual Property Rights</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
The Service and its original content, features, and
functionality are and will remain the exclusive property of
beenvoice and its licensors. The Service is protected by
copyright, trademark, and other laws.
</p>
<p>
Our trademarks and trade dress may not be used in connection
with any product or service without our prior written consent.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Termination</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
We may terminate or suspend your account and bar access to the
Service immediately, without prior notice or liability, under
our sole discretion, for any reason whatsoever and without
limitation, including but not limited to a breach of the Terms.
</p>
<p>
If you wish to terminate your account, you may simply
discontinue using the Service and contact us to request account
deletion.
</p>
<p>
Upon termination, your right to use the Service will cease
immediately. If you wish to terminate your account, you may
simply discontinue using the Service.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Disclaimer of Warranties</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
The information on this Service is provided on an &quot;as
is&quot; basis. To the fullest extent permitted by law, we
exclude all representations, warranties, and conditions relating
to our Service and the use of this Service.
</p>
<p>
Nothing in this disclaimer will limit or exclude our or your
liability for death or personal injury resulting from
negligence, fraud, or fraudulent misrepresentation.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Limitation of Liability</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
In no event shall beenvoice, nor its directors, employees,
partners, agents, suppliers, or affiliates, be liable for any
indirect, incidental, special, consequential, or punitive
damages, including without limitation, loss of profits, data,
use, goodwill, or other intangible losses, resulting from your
use of the Service.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Governing Law</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
These Terms shall be interpreted and governed by the laws of the
jurisdiction in which beenvoice operates, without regard to its
conflict of law provisions.
</p>
<p>
Our failure to enforce any right or provision of these Terms
will not be considered a waiver of those rights.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Changes to Terms</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
We reserve the right, at our sole discretion, to modify or
replace these Terms at any time. If a revision is material, we
will provide at least 30 days notice prior to any new terms
taking effect.
</p>
<p>
What constitutes a material change will be determined at our
sole discretion. By continuing to access or use our Service
after any revisions become effective, you agree to be bound by
the revised terms.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Contact Information</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none">
<p>
If you have any questions about these Terms of Service, please
contact us at:
</p>
<ul>
<li>Email: legal@beenvoice.com</li>
<li>Address: [Your Business Address]</li>
</ul>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "~/lib/auth";
export const { GET, POST } = toNextJsHandler(auth);
-3
View File
@@ -1,3 +0,0 @@
import { handlers } from "~/server/auth";
export const { GET, POST } = handlers;
+115
View File
@@ -0,0 +1,115 @@
import { type NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
import { Resend } from "resend";
import { env } from "~/env";
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
import crypto from "crypto";
export async function POST(request: NextRequest) {
try {
const { email } = (await request.json()) as { email: string };
if (!email || typeof email !== "string") {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: "Invalid email format" },
{ status: 400 },
);
}
// Check if user exists
const user = await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()),
});
// Always return success to prevent email enumeration attacks
// Don't reveal whether the user exists or not
if (!user) {
return NextResponse.json(
{
success: true,
message:
"If an account with that email exists, password reset instructions have been sent.",
},
{ status: 200 },
);
}
// Generate reset token
const resetToken = crypto.randomBytes(32).toString("hex");
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Update user with reset token
await db
.update(users)
.set({
resetToken,
resetTokenExpiry,
})
.where(eq(users.id, user.id));
if (!env.RESEND_API_KEY) {
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return NextResponse.json(
{
success: true,
message:
"If an account with that email exists, password reset instructions have been sent.",
},
{ status: 200 },
);
}
// Send password reset email using Resend
try {
const resend = new Resend(env.RESEND_API_KEY);
const resetUrl = `${process.env.BETTER_AUTH_URL ?? "http://localhost:3000"}/auth/reset-password?token=${resetToken}`;
const emailTemplate = generatePasswordResetEmailTemplate({
userEmail: email,
userName: user.name ?? undefined,
resetToken,
resetUrl,
expiryHours: 24,
});
await resend.emails.send({
from: "beenvoice <noreply@beenvoice.com>",
to: email,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text,
});
console.log(`Password reset email sent to: ${email}`);
} catch (emailError) {
console.error("Failed to send password reset email:", emailError);
// Continue execution - don't fail the request if email fails
// This prevents revealing whether an account exists based on email delivery
}
return NextResponse.json(
{
success: true,
message:
"If an account with that email exists, password reset instructions have been sent.",
},
{ status: 200 },
);
} catch (error) {
console.error("Password reset error:", error);
return NextResponse.json(
{ error: "An error occurred while processing your request" },
{ status: 500 },
);
}
}
+54 -16
View File
@@ -2,59 +2,97 @@ import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { env } from "~/env";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
import { accounts, users } from "~/server/db/schema";
const registerSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().trim().min(1, "Last name is required"),
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) {
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 normalizedEmail = email.toLowerCase();
// Check if user already exists
const existingUser = await db.query.users.findFirst({
where: eq(users.email, email),
where: eq(users.email, normalizedEmail),
});
if (existingUser) {
return NextResponse.json(
{ error: "User with this email already exists" },
{ status: 400 }
{ status: 400 },
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
await db.insert(users).values({
name: `${firstName} ${lastName}`,
email,
password: hashedPassword,
await db.transaction(async (tx) => {
const [user] = await tx
.insert(users)
.values({
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(
{ message: "User created successfully" },
{ status: 201 }
{ status: 201 },
);
} catch (error) {
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(
{ error: error.errors[0]?.message ?? "Validation error" },
{ status: 400 }
{ error: issue?.message === "Required" ? fallback : issue?.message },
{ status: 400 },
);
}
console.error("Registration error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
{ status: 500 },
);
}
}
+99
View File
@@ -0,0 +1,99 @@
import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema";
export async function POST(request: NextRequest) {
try {
const { token, password } = (await request.json()) as {
token: string;
password: string;
};
if (!token || typeof token !== "string") {
return NextResponse.json({ error: "Token is required" }, { status: 400 });
}
if (!password || typeof password !== "string") {
return NextResponse.json(
{ error: "Password is required" },
{ status: 400 },
);
}
if (password.length < 8) {
return NextResponse.json(
{ error: "Password must be at least 8 characters long" },
{ status: 400 },
);
}
// Find user with valid reset token that hasn't expired
const user = await db.query.users.findFirst({
where: and(
eq(users.resetToken, token),
gt(users.resetTokenExpiry, new Date()),
),
});
if (!user) {
return NextResponse.json(
{ error: "Invalid or expired token" },
{ status: 400 },
);
}
// Hash the new password
const hashedPassword = await bcrypt.hash(password, 12);
await db.transaction(async (tx) => {
await tx
.update(users)
.set({
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null,
})
.where(eq(users.id, user.id));
const credentialAccount = await tx.query.accounts.findFirst({
where: and(
eq(accounts.userId, user.id),
eq(accounts.providerId, "credential"),
),
});
if (credentialAccount) {
await tx
.update(accounts)
.set({
password: hashedPassword,
updatedAt: new Date(),
})
.where(eq(accounts.id, credentialAccount.id));
} else {
await tx.insert(accounts).values({
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
});
}
});
return NextResponse.json(
{
success: true,
message: "Password has been reset successfully",
},
{ status: 200 },
);
} catch (error) {
console.error("Password reset error:", error);
return NextResponse.json(
{ error: "An error occurred while resetting your password" },
{ status: 500 },
);
}
}
@@ -0,0 +1,37 @@
import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
export async function POST(request: NextRequest) {
try {
const { token } = (await request.json()) as { token: string };
if (!token || typeof token !== "string") {
return NextResponse.json({ error: "Token is required" }, { status: 400 });
}
// Find user with valid reset token that hasn't expired
const user = await db.query.users.findFirst({
where: and(
eq(users.resetToken, token),
gt(users.resetTokenExpiry, new Date()),
),
});
if (!user) {
return NextResponse.json(
{ error: "Invalid or expired token" },
{ status: 400 },
);
}
return NextResponse.json({ valid: true }, { status: 200 });
} catch (error) {
console.error("Token validation error:", error);
return NextResponse.json(
{ error: "An error occurred while validating the token" },
{ status: 500 },
);
}
}
+385
View File
@@ -0,0 +1,385 @@
"use client";
import { useState, Suspense } from "react";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalModal } from "~/components/ui/legal-modal";
import {
Mail,
ArrowRight,
ArrowLeft,
Shield,
Clock,
CheckCircle,
} from "lucide-react";
function ForgotPasswordForm() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
const response = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
const data = (await response.json()) as { error?: string };
if (response.ok) {
setSent(true);
toast.success("Password reset instructions sent to your email");
} else {
toast.error(data.error ?? "Failed to send reset email");
}
} catch {
toast.error("An error occurred. Please try again.");
} finally {
setLoading(false);
}
}
if (sent) {
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="text-3xl font-bold lg:text-4xl">
Check your
<span className="text-primary"> email inbox</span>
</h1>
<p className="text-muted-foreground text-lg">
We&apos;ve sent password reset instructions to your email
address. Follow the link to create a new password.
</p>
</div>
</div>
<div className="grid gap-4">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Mail className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Check your inbox</h3>
<p className="text-muted-foreground text-sm">
Look for an email from beenvoice with reset instructions
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Clock className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Link expires soon</h3>
<p className="text-muted-foreground text-sm">
The reset link is valid for 24 hours only
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Shield className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Secure Process</h3>
<p className="text-muted-foreground text-sm">
Your account security is our top priority
</p>
</div>
</div>
</div>
<div className="bg-primary/5 flex items-center space-x-4 rounded-lg p-4">
<CheckCircle className="text-primary h-8 w-8" />
<div>
<p className="font-semibold">Email sent successfully</p>
<p className="text-muted-foreground text-sm">
Follow the instructions in your email to reset your
password
</p>
</div>
</div>
</div>
</div>
{/* Success Message */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center">
<div className="bg-primary/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<CheckCircle className="text-primary h-8 w-8" />
</div>
<h1 className="text-2xl font-bold">Check your email</h1>
<p className="text-muted-foreground">
We&apos;ve sent password reset instructions to{" "}
<span className="font-medium">{email}</span>
</p>
</div>
<div className="bg-muted/50 space-y-3 rounded-lg p-4">
<h3 className="font-semibold">What&apos;s next?</h3>
<ul className="space-y-2 text-sm">
<li className="flex items-start space-x-2">
<span className="text-primary">1.</span>
<span>Check your email inbox (and spam folder)</span>
</li>
<li className="flex items-start space-x-2">
<span className="text-primary">2.</span>
<span>Click the reset link in the email</span>
</li>
<li className="flex items-start space-x-2">
<span className="text-primary">3.</span>
<span>Create a new secure password</span>
</li>
</ul>
</div>
<div className="space-y-3">
<Button
onClick={() => {
setSent(false);
setEmail("");
}}
variant="outline"
className="h-11 w-full"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Try a different email
</Button>
<a href="/auth/signin">
<Button className="h-11 w-full">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Sign In
</Button>
</a>
</div>
<div className="text-muted-foreground text-center text-xs">
Didn&apos;t receive the email? Check your spam folder or{" "}
<button
onClick={() => {
setSent(false);
toast.info("You can try sending the email again");
}}
className="text-primary hover:underline"
>
try again
</button>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="text-3xl font-bold lg:text-4xl">
Forgot your
<span className="text-primary"> password?</span>
</h1>
<p className="text-muted-foreground text-lg">
No worries! Enter your email address and we&apos;ll send you
instructions to reset your password.
</p>
</div>
</div>
<div className="grid gap-4">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Mail className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Email Instructions</h3>
<p className="text-muted-foreground text-sm">
We&apos;ll send a secure link to your email address
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Clock className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Quick Process</h3>
<p className="text-muted-foreground text-sm">
Reset your password in just a few clicks
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Shield className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Secure & Safe</h3>
<p className="text-muted-foreground text-sm">
Your account security is our top priority
</p>
</div>
</div>
</div>
</div>
</div>
{/* Forgot Password Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center md:text-left">
<h1 className="text-2xl font-bold">Forgot Password</h1>
<p className="text-muted-foreground">
Enter your email and we&apos;ll send you reset instructions
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="h-11 pl-10"
placeholder="Enter your email address"
/>
</div>
</div>
<Button
type="submit"
className="h-11 w-full"
disabled={loading}
>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
<span>Sending instructions...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Send Reset Instructions</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-start space-x-3">
<Mail className="text-primary mt-0.5 h-4 w-4 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium">Check your spam folder</p>
<p className="text-muted-foreground text-sm">
Sometimes our emails end up in spam or promotions folders
</p>
</div>
</div>
</div>
<div className="text-center">
<a
href="/auth/signin"
className="text-primary inline-flex items-center space-x-1 text-sm font-medium hover:underline"
>
<ArrowLeft className="h-3 w-3" />
<span>Back to Sign In</span>
</a>
</div>
<div className="text-muted-foreground text-center text-xs">
Remember your password?{" "}
<a
href="/auth/signin"
className="text-primary font-medium hover:underline"
>
Sign in instead
</a>
</div>
<div className="text-muted-foreground text-center text-xs leading-relaxed">
By using our service, you agree to our{" "}
<LegalModal
type="terms"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Terms of Service
</span>
}
/>{" "}
and{" "}
<LegalModal
type="privacy"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Privacy Policy
</span>
}
/>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function ForgotPasswordPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ForgotPasswordForm />
</Suspense>
);
}
+203 -138
View File
@@ -1,20 +1,19 @@
"use client";
import Link from "next/link";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { User, Mail, Lock, ArrowRight } from "lucide-react";
import { LegalModal } from "~/components/ui/legal-modal";
import { Mail, Lock, ArrowRight, User, Clock, Rocket, Zap } from "lucide-react";
function RegisterForm() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
@@ -24,6 +23,7 @@ function RegisterForm() {
async function handleRegister(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -34,163 +34,228 @@ function RegisterForm() {
password,
}),
});
setLoading(false);
if (res.ok) {
toast.success("Account created successfully! Please sign in.");
const signInUrl =
callbackUrl !== "/dashboard"
? `/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
: "/auth/signin";
router.push(signInUrl);
router.push("/auth/signin");
} else {
const error = await res.text();
toast.error(error || "Failed to create account");
const data = (await res.json()) as { error?: string };
toast.error(data.error ?? "Registration failed");
}
}
return (
<div className="auth-container">
<div className="auth-form-container">
{/* Logo and Welcome */}
<div className="auth-header">
<Logo size="lg" className="mx-auto" />
<div>
<h1 className="auth-title">Join beenvoice</h1>
<p className="auth-subtitle">Create your account to get started</p>
</div>
</div>
<div className="bg-background flex min-h-screen items-center justify-center">
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:border md:shadow-lg">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Logo size="xl" />
</div>
<div className="space-y-3">
<h1 className="text-3xl font-bold lg:text-4xl">
Start your
<span className="text-primary"> invoicing journey</span>
</h1>
<p className="text-muted-foreground text-lg">
Join thousands of freelancers and small businesses who trust
beenvoice to manage their invoicing and get paid faster.
</p>
</div>
</div>
{/* Registration Form */}
<Card className="auth-card">
<CardHeader className="space-y-1">
<CardTitle className="auth-card-title">Create Account</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleRegister} className="auth-form">
<div className="auth-input-grid">
<div className="auth-input-group">
<Label htmlFor="firstName">First Name</Label>
<div className="relative">
<User className="auth-input-icon" />
<Input
id="firstName"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
autoFocus
className="form-input-with-icon"
placeholder="First name"
/>
<div className="grid gap-4">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Rocket className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Quick Setup</h3>
<p className="text-muted-foreground text-sm">
Get started in minutes with our intuitive setup wizard
</p>
</div>
</div>
<div className="auth-input-group">
<Label htmlFor="lastName">Last Name</Label>
<div className="relative">
<User className="auth-input-icon" />
<Input
id="lastName"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
className="form-input-with-icon"
placeholder="Last name"
/>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Zap className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Fast Payments</h3>
<p className="text-muted-foreground text-sm">
Professional invoices that get you paid 3x faster
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Clock className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Time Tracking</h3>
<p className="text-muted-foreground text-sm">
Track time and convert it to accurate invoices instantly
</p>
</div>
</div>
</div>
<div className="auth-input-group">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="auth-input-icon" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="form-input-with-icon"
placeholder="Enter your email"
/>
</div>
</div>
</div>
{/* Sign Up Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="auth-input-group">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="auth-input-icon" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="form-input-with-icon"
placeholder="Create a password"
/>
</div>
<p className="auth-password-help">
Must be at least 6 characters
<div className="space-y-2 text-center md:text-left">
<h1 className="text-2xl font-bold">Create your account</h1>
<p className="text-muted-foreground">
Supercharge your invoicing today
</p>
</div>
<Button
type="submit"
className="auth-submit-btn"
disabled={loading}
>
{loading ? (
"Creating account..."
) : (
<>
Create Account
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</form>
<div className="auth-footer-text">
<span className="text-muted-foreground">
Already have an account?{" "}
</span>
<Link href="/auth/signin" className="auth-footer-link">
Sign in here
</Link>
</div>
</CardContent>
</Card>
{/* Features */}
<div className="auth-features">
<p className="welcome-description">Start invoicing like a pro</p>
<div className="auth-features-list">
<span> Free to start</span>
<span> No credit card</span>
<span> Cancel anytime</span>
<form onSubmit={handleRegister} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<div className="relative">
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="firstName"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
autoFocus
className="h-11 pl-10"
placeholder="John"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<div className="relative">
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="lastName"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
className="h-11 pl-10"
placeholder="Doe"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="h-11 pl-10"
placeholder="john@example.com"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="h-11 pl-10"
placeholder="Create a strong password"
/>
</div>
<p className="text-muted-foreground text-xs">
Must be at least 8 characters long
</p>
</div>
<Button
type="submit"
className="h-11 w-full"
disabled={loading}
>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
<span>Creating account...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Create Account</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
<div className="text-center text-sm">
Already have an account?{" "}
<a
href="/auth/signin"
className="text-primary font-medium hover:underline"
>
Sign in
</a>
</div>
<div className="text-muted-foreground text-center text-xs leading-relaxed">
By creating an account, you agree to our{" "}
<LegalModal
type="terms"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Terms of Service
</span>
}
/>{" "}
and{" "}
<LegalModal
type="privacy"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Privacy Policy
</span>
}
/>
.
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function RegisterPage() {
return (
<Suspense
fallback={
<div className="auth-container">
<div className="auth-form-container">
<div className="auth-header">
<Logo size="lg" className="mx-auto" />
<div>
<h1 className="auth-title">Join beenvoice</h1>
<p className="auth-subtitle">Loading...</p>
</div>
</div>
</div>
</div>
}
>
<Suspense fallback={<div>Loading...</div>}>
<RegisterForm />
</Suspense>
);
+462
View File
@@ -0,0 +1,462 @@
"use client";
import { useState, Suspense, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalModal } from "~/components/ui/legal-modal";
import {
Lock,
ArrowRight,
ArrowLeft,
CheckCircle,
Shield,
Eye,
EyeOff,
} from "lucide-react";
function ResetPasswordForm() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [tokenValid, setTokenValid] = useState<boolean | null>(null);
useEffect(() => {
if (!token) {
setTokenValid(false);
return;
}
// Validate token on page load
const validateToken = async () => {
try {
const response = await fetch("/api/auth/validate-reset-token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token }),
});
if (response.ok) {
setTokenValid(true);
} else {
setTokenValid(false);
}
} catch {
setTokenValid(false);
}
};
void validateToken();
}, [token]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!token) {
toast.error("Invalid reset token");
return;
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters long");
return;
}
if (password !== confirmPassword) {
toast.error("Passwords do not match");
return;
}
setLoading(true);
try {
const response = await fetch("/api/auth/reset-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token, password }),
});
const data = (await response.json()) as { error?: string };
if (response.ok) {
setSuccess(true);
toast.success("Password reset successfully!");
} else {
toast.error(data.error ?? "Failed to reset password");
}
} catch {
toast.error("An error occurred. Please try again.");
} finally {
setLoading(false);
}
}
if (tokenValid === null) {
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<div className="text-center">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"></div>
<p className="text-muted-foreground mt-4">
Validating reset token...
</p>
</div>
</div>
);
}
if (tokenValid === false) {
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="text-3xl font-bold lg:text-4xl">
Invalid or
<span className="text-destructive"> expired link</span>
</h1>
<p className="text-muted-foreground text-lg">
This password reset link is either invalid or has expired.
Please request a new password reset.
</p>
</div>
</div>
<div className="grid gap-4">
<div className="flex items-start space-x-4">
<div className="bg-destructive/10 rounded-lg p-2">
<Shield className="text-destructive h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Security First</h3>
<p className="text-muted-foreground text-sm">
Reset links expire after 24 hours for your security
</p>
</div>
</div>
</div>
</div>
</div>
{/* Error Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center">
<div className="bg-destructive/10 justify-content mx-auto mb-4 flex h-16 w-16 items-center rounded-full">
<Shield className="text-destructive mx-auto h-8 w-8" />
</div>
<h1 className="text-2xl font-bold">Link Expired</h1>
<p className="text-muted-foreground">
This password reset link is no longer valid
</p>
</div>
<div className="space-y-3">
<a href="/auth/forgot-password">
<Button className="h-11 w-full">
Request New Reset Link
</Button>
</a>
<a href="/auth/signin">
<Button variant="outline" className="h-11 w-full">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Sign In
</Button>
</a>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
if (success) {
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="text-3xl font-bold lg:text-4xl">
Password
<span className="text-primary"> reset complete</span>
</h1>
<p className="text-muted-foreground text-lg">
Your password has been successfully reset. You can now
sign in with your new password.
</p>
</div>
</div>
<div className="bg-primary/5 rounded-lg p-4">
<div className="flex items-center space-x-3">
<CheckCircle className="text-primary h-6 w-6" />
<div>
<p className="font-semibold">Security Updated</p>
<p className="text-muted-foreground text-sm">
Your account is now secured with your new password
</p>
</div>
</div>
</div>
</div>
</div>
{/* Success Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center">
<div className="bg-primary/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<CheckCircle className="text-primary h-8 w-8" />
</div>
<h1 className="text-2xl font-bold">
Password Reset Complete
</h1>
<p className="text-muted-foreground">
Your password has been successfully updated
</p>
</div>
<div className="space-y-3">
<a href="/auth/signin">
<Button className="h-11 w-full">
<ArrowRight className="mr-2 h-4 w-4" />
Sign In Now
</Button>
</a>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="bg-background flex min-h-screen items-center justify-center">
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-4xl md:border md:shadow-lg">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-muted relative hidden md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="text-3xl font-bold lg:text-4xl">
Create your
<span className="text-primary"> new password</span>
</h1>
<p className="text-muted-foreground text-lg">
Choose a strong password to secure your beenvoice account.
Make sure it&apos;s something you&apos;ll remember.
</p>
</div>
</div>
<div className="grid gap-4">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Shield className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Secure Password</h3>
<p className="text-muted-foreground text-sm">
Use at least 8 characters with a mix of letters and
numbers
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-lg p-2">
<Lock className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">Account Safety</h3>
<p className="text-muted-foreground text-sm">
Your new password will immediately secure your account
</p>
</div>
</div>
</div>
</div>
</div>
{/* Reset Password Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center md:text-left">
<h1 className="text-2xl font-bold">Reset Password</h1>
<p className="text-muted-foreground">
Enter your new password below
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="h-11 pr-10 pl-10"
placeholder="Enter new password"
minLength={8}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 z-10 -translate-y-1/2"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<p className="text-muted-foreground text-xs">
Must be at least 8 characters long
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="h-11 pr-10 pl-10"
placeholder="Confirm new password"
/>
<button
type="button"
onClick={() =>
setShowConfirmPassword(!showConfirmPassword)
}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 z-10 -translate-y-1/2"
>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<Button
type="submit"
className="h-11 w-full"
disabled={loading}
>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
<span>Updating password...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Update Password</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
<div className="text-center">
<a
href="/auth/signin"
className="text-primary inline-flex items-center space-x-1 text-sm font-medium hover:underline"
>
<ArrowLeft className="h-3 w-3" />
<span>Back to Sign In</span>
</a>
</div>
<div className="text-muted-foreground text-center text-xs leading-relaxed">
By resetting your password, you agree to our{" "}
<LegalModal
type="terms"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Terms of Service
</span>
}
/>{" "}
and{" "}
<LegalModal
type="privacy"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Privacy Policy
</span>
}
/>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ResetPasswordForm />
</Suspense>
);
}
+5 -150
View File
@@ -1,156 +1,11 @@
"use client";
import Link from "next/link";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { signIn } from "next-auth/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { Mail, Lock, ArrowRight } from "lucide-react";
function SignInForm() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
setLoading(false);
if (result?.error) {
toast.error("Invalid email or password");
} else {
toast.success("Signed in successfully!");
router.push(callbackUrl);
router.refresh();
}
}
return (
<div className="bg-gradient-auth flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
{/* Logo and Welcome */}
<div className="space-y-4 text-center">
<Logo size="lg" className="mx-auto" />
<div>
<h1 className="text-foreground text-2xl font-bold">Welcome back</h1>
<p className="text-muted-foreground mt-2">
Sign in to your beenvoice account
</p>
</div>
</div>
{/* Sign In Form */}
<Card className="card-primary">
<CardHeader className="space-y-1">
<CardTitle className="text-center text-xl">Sign In</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="form-icon-left" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="form-input-with-icon"
placeholder="Enter your email"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="form-icon-left" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="form-input-with-icon"
placeholder="Enter your password"
/>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
"Signing in..."
) : (
<>
Sign In
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</form>
<div className="mt-6 text-center text-sm">
<span className="text-muted-foreground">
Don&apos;t have an account?{" "}
</span>
<Link href="/auth/register" className="nav-link-brand">
Create one now
</Link>
</div>
</CardContent>
</Card>
{/* Features */}
<div className="space-y-4 text-center">
<p className="welcome-description">
Simple invoicing for freelancers and small businesses
</p>
<div className="welcome-feature-list">
<span> Easy client management</span>
<span> Professional invoices</span>
<span> Payment tracking</span>
</div>
</div>
</div>
</div>
);
}
import { Suspense } from "react";
import { env } from "~/env";
import { SignInForm } from "./signin-form";
export default function SignInPage() {
return (
<Suspense
fallback={
<div className="bg-gradient-auth flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
<div className="space-y-4 text-center">
<Logo size="lg" className="mx-auto" />
<div>
<h1 className="text-foreground text-2xl font-bold">
Welcome back
</h1>
<p className="text-muted-foreground mt-2">Loading...</p>
</div>
</div>
</div>
</div>
}
>
<SignInForm />
<Suspense fallback={<div>Loading...</div>}>
<SignInForm allowRegistration={env.DISABLE_SIGNUPS !== true} />
</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>
);
}
-44
View File
@@ -1,44 +0,0 @@
import { auth } from "~/server/auth";
import { HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { ClientForm } from "~/components/forms/client-form";
import Link from "next/link";
interface EditClientPageProps {
params: Promise<{
id: string;
}>;
}
export default async function EditClientPage({ params }: EditClientPageProps) {
const { id } = await params;
const session = await auth();
if (!session?.user) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">Access Denied</h1>
<p className="text-muted-foreground mb-8">
Please sign in to edit clients
</p>
<Link href="/api/auth/signin">
<Button size="lg">Sign In</Button>
</Link>
</div>
</div>
);
}
return (
<HydrateClient>
<div className="p-6">
<div className="mb-8">
<h2 className="mb-2 text-3xl font-bold">Edit Client</h2>
<p className="text-muted-foreground">Update client information</p>
</div>
<ClientForm mode="edit" clientId={id} />
</div>
</HydrateClient>
);
}
-20
View File
@@ -1,20 +0,0 @@
import { Navbar } from "~/components/layout/navbar";
import { Sidebar } from "~/components/layout/sidebar";
export default function ClientsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Navbar />
<div className="flex">
<Sidebar />
<main className="flex-1 min-h-screen bg-background">
{children}
</main>
</div>
</>
);
}
-37
View File
@@ -1,37 +0,0 @@
import { auth } from "~/server/auth";
import { HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { ClientForm } from "~/components/forms/client-form";
import Link from "next/link";
export default async function NewClientPage() {
const session = await auth();
if (!session?.user) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">Access Denied</h1>
<p className="text-muted-foreground mb-8">Please sign in to create clients</p>
<Link href="/api/auth/signin">
<Button size="lg">Sign In</Button>
</Link>
</div>
</div>
);
}
return (
<HydrateClient>
<div className="p-6">
<div className="mb-8">
<h2 className="text-3xl font-bold mb-2">Add New Client</h2>
<p className="text-muted-foreground">
Create a new client profile
</p>
</div>
<ClientForm mode="create" />
</div>
</HydrateClient>
);
}
-42
View File
@@ -1,42 +0,0 @@
import Link from "next/link";
import { auth } from "~/server/auth";
import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { ClientList } from "~/components/data/client-list";
import { Plus } from "lucide-react";
export default async function ClientsPage() {
const session = await auth();
if (!session?.user) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">Access Denied</h1>
<p className="text-muted-foreground mb-8">Please sign in to view clients</p>
<Link href="/api/auth/signin">
<Button size="lg">Sign In</Button>
</Link>
</div>
</div>
);
}
// Prefetch clients data
void api.clients.getAll.prefetch();
return (
<HydrateClient>
<div className="p-6">
<div className="mb-8">
<h2 className="text-3xl font-bold mb-2">Clients</h2>
<p className="text-muted-foreground">
Manage your client relationships
</p>
</div>
<ClientList />
</div>
</HydrateClient>
);
}
@@ -0,0 +1,92 @@
"use client";
import {
TrendingDown,
TrendingUp,
Minus,
DollarSign,
Clock,
Users,
} from "lucide-react";
import { Card, CardContent } from "~/components/ui/card";
type IconName = "DollarSign" | "Clock" | "Users" | "TrendingDown";
interface AnimatedStatsCardProps {
title: string;
value: string;
change: string;
trend: "up" | "down" | "neutral";
iconName: IconName;
description: string;
delay?: number;
isCurrency?: boolean;
numericValue?: number;
}
const iconMap = {
DollarSign,
Clock,
Users,
TrendingDown,
} as const;
export function AnimatedStatsCard({
title,
value,
change,
trend,
iconName,
description,
delay = 0,
isCurrency = false,
numericValue,
}: AnimatedStatsCardProps) {
const Icon = iconMap[iconName];
let TrendIcon = Minus;
if (trend === "up") TrendIcon = TrendingUp;
if (trend === "down") TrendIcon = TrendingDown;
const isPositive = trend === "up";
const isNeutral = trend === "neutral";
// For now, always use the formatted value prop to ensure correct display
// Animation can be added back once the basic display is working correctly
const displayValue = value;
// Suppress unused parameter warnings for now
void delay;
void isCurrency;
void numericValue;
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between space-y-0 pb-2">
<div className="flex items-center space-x-2">
<Icon className="text-muted-foreground h-5 w-5" />
<p className="text-muted-foreground text-sm font-medium">{title}</p>
</div>
<div
className="flex items-center space-x-1 text-xs"
style={{
color: isNeutral
? "hsl(var(--muted-foreground))"
: isPositive
? "oklch(var(--chart-2))"
: "oklch(var(--chart-3))",
}}
>
<TrendIcon className="h-3 w-3" />
<span>{change}</span>
</div>
</div>
<div className="space-y-1">
<p className="animate-count-up text-2xl font-bold">{displayValue}</p>
<p className="text-muted-foreground text-xs">{description}</p>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,163 @@
"use client";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
interface Invoice {
id: string;
totalAmount: number;
status: string;
dueDate: Date | string;
}
interface InvoiceStatusChartProps {
invoices: Invoice[];
}
export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
// Process invoice data to create status breakdown
const statusData = invoices.reduce(
(acc, invoice) => {
const effectiveStatus = getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
acc[effectiveStatus] ??= {
status: effectiveStatus,
count: 0,
value: 0,
};
acc[effectiveStatus].count += 1;
acc[effectiveStatus].value += invoice.totalAmount;
return acc;
},
{} as Record<string, { status: string; count: number; value: number }>,
);
const chartData = Object.values(statusData).map((item) => ({
...item,
name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
}));
// Use theme-aware colors
const COLORS = {
draft: "hsl(0, 0%, 60%)", // neutral grey - matches monthly metrics chart
sent: "hsl(217, 91%, 60%)", // vibrant blue
pending: "hsl(217, 91%, 60%)", // blue
paid: "hsl(142, 71%, 45%)", // vibrant green
overdue: "hsl(var(--destructive))", // red
};
// Animation / motion preferences
const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences();
const pieAnimationDuration = Math.round(
600 / (animationSpeedMultiplier || 1),
);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const CustomTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: Array<{
payload: { name: string; count: number; value: number };
}>;
}) => {
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{data.name}</p>
<p className="text-sm">
{data.count} invoice{data.count !== 1 ? "s" : ""}
</p>
<p className="text-sm">{formatCurrency(data.value)}</p>
</div>
);
}
return null;
};
if (chartData.length === 0) {
return (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground text-sm">
No invoice data available
</p>
<p className="text-muted-foreground text-xs">
Status breakdown will appear here once you create invoices
</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={80}
stroke="none"
dataKey="count"
isAnimationActive={!prefersReducedMotion}
animationDuration={pieAnimationDuration}
animationEasing="ease-out"
>
{chartData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[entry.status as keyof typeof COLORS]}
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="space-y-2">
{chartData.map((item) => (
<div key={item.status} className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div
className="h-3 w-3 rounded-full"
style={{
backgroundColor: COLORS[item.status as keyof typeof COLORS],
}}
/>
<span className="text-sm font-medium">{item.name}</span>
</div>
<div className="text-right">
<p className="text-sm font-medium">{item.count}</p>
<p className="text-muted-foreground text-xs">
{formatCurrency(item.value)}
</p>
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,246 @@
"use client";
import {
Bar,
BarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface Invoice {
id: string;
totalAmount: number;
issueDate: Date | string;
status: string;
dueDate: Date | string;
}
interface MonthlyMetricsChartProps {
invoices: Invoice[];
}
export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
// Process invoice data to create monthly metrics
const monthlyData = invoices.reduce(
(acc, invoice) => {
const date = new Date(invoice.issueDate);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
const effectiveStatus = getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
acc[monthKey] ??= {
month: monthKey,
totalInvoices: 0,
paidInvoices: 0,
pendingInvoices: 0,
overdueInvoices: 0,
draftInvoices: 0,
};
acc[monthKey].totalInvoices += 1;
switch (effectiveStatus) {
case "paid":
acc[monthKey].paidInvoices += 1;
break;
case "sent":
acc[monthKey].pendingInvoices += 1;
break;
case "overdue":
acc[monthKey].overdueInvoices += 1;
break;
case "draft":
acc[monthKey].draftInvoices += 1;
break;
}
return acc;
},
{} as Record<
string,
{
month: string;
totalInvoices: number;
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
}
>,
);
// Convert to array and sort by month
const chartData = Object.values(monthlyData)
.sort((a, b) => a.month.localeCompare(b.month))
.slice(-6) // Show last 6 months
.map((item) => ({
...item,
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
}));
// Animation / motion preferences
const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences();
const barAnimationDuration = Math.round(
500 / (animationSpeedMultiplier || 1),
);
const CustomTooltip = ({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
payload: {
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
totalInvoices: number;
};
}>;
label?: string;
}) => {
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p>
<div className="space-y-1 text-sm">
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
<p className="text-primary/80">
Pending: {data.pendingInvoices}
</p>
<p className="text-destructive">
Overdue: {data.overdueInvoices}
</p>
<p className="text-muted-foreground">
Draft: {data.draftInvoices}
</p>
<p className="text-foreground font-medium border-t pt-1">
Total: {data.totalInvoices}
</p>
</div>
</div>
);
}
return null;
};
if (chartData.length === 0) {
return (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground text-sm">
No metrics data available
</p>
<p className="text-muted-foreground text-xs">
Monthly metrics will appear here once you create invoices
</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<XAxis
dataKey="monthLabel"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
/>
<Tooltip content={<CustomTooltip />} />
<Bar
dataKey="draftInvoices"
stackId="a"
fill="hsl(0, 0%, 60%)"
radius={[0, 0, 0, 0]}
isAnimationActive={!prefersReducedMotion}
animationDuration={barAnimationDuration}
animationEasing="ease-out"
/>
<Bar
dataKey="paidInvoices"
stackId="a"
fill="hsl(142, 71%, 45%)"
radius={[0, 0, 0, 0]}
isAnimationActive={!prefersReducedMotion}
animationDuration={barAnimationDuration}
animationEasing="ease-out"
/>
<Bar
dataKey="pendingInvoices"
stackId="a"
fill="hsl(217, 91%, 60%)"
fillOpacity={0.6}
radius={[0, 0, 0, 0]}
isAnimationActive={!prefersReducedMotion}
animationDuration={barAnimationDuration}
animationEasing="ease-out"
/>
<Bar
dataKey="overdueInvoices"
stackId="a"
fill="hsl(var(--destructive))"
radius={[2, 2, 0, 0]}
isAnimationActive={!prefersReducedMotion}
animationDuration={barAnimationDuration}
animationEasing="ease-out"
/>
</BarChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">
<div className="flex items-center space-x-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: "hsl(0, 0%, 60%)" }}
/>
<span className="text-xs">Draft</span>
</div>
<div className="flex items-center space-x-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: "hsl(142, 71%, 45%)" }}
/>
<span className="text-xs">Paid</span>
</div>
<div className="flex items-center space-x-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: "hsl(217, 91%, 60%)", opacity: 0.6 }}
/>
<span className="text-xs">Pending</span>
</div>
<div className="flex items-center space-x-2">
<div
className="h-3 w-3 rounded-full bg-destructive"
/>
<span className="text-xs">Overdue</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,131 @@
"use client";
import {
Area,
AreaChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface RevenueChartProps {
data: {
month: string;
revenue: number;
monthLabel: string;
}[];
}
const CustomTooltip = ({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{ payload: { revenue: number } }>;
label?: string;
}) => {
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p>
<p style={{ color: "hsl(0, 0%, 60%)" }}>
Revenue: {formatCurrency(data.revenue)}
</p>
<p className="text-muted-foreground text-sm">
{/* Count not available in aggregated view currently */}
</p>
</div>
);
}
return null;
};
export function RevenueChart({ data }: RevenueChartProps) {
// Use data directly
const chartData = data;
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences();
if (chartData.length === 0) {
return (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground text-sm">
No revenue data available
</p>
<p className="text-muted-foreground text-xs">
Revenue will appear here once you have paid invoices
</p>
</div>
</div>
);
}
return (
<div className="h-48 w-full md:h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0.4} />
<stop
offset="95%"
stopColor="hsl(217, 91%, 60%)"
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<XAxis
dataKey="monthLabel"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
tickFormatter={formatCurrency}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="revenue"
stroke="hsl(217, 91%, 60%)"
strokeWidth={2}
fill="url(#revenueGradient)"
isAnimationActive={!prefersReducedMotion}
animationDuration={Math.round(
600 / (animationSpeedMultiplier ?? 1),
)}
animationEasing="ease-out"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,343 @@
"use client";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import {
Send,
DollarSign,
FileText,
AlertCircle,
Clock,
CheckCircle,
RefreshCw,
Calendar,
Loader2,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import {
getEffectiveInvoiceStatus,
isInvoiceOverdue,
getDaysPastDue,
getStatusConfig,
} from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
interface StatusManagerProps {
invoiceId: string;
currentStatus: StoredInvoiceStatus;
dueDate: Date;
clientEmail?: string | null;
onStatusChange?: () => void;
}
const statusIconConfig = {
draft: FileText,
sent: Send,
paid: CheckCircle,
overdue: AlertCircle,
};
export function StatusManager({
invoiceId,
currentStatus,
dueDate,
clientEmail,
onStatusChange,
}: StatusManagerProps) {
const [isChangingStatus, setIsChangingStatus] = useState(false);
const utils = api.useUtils();
const updateStatus = api.invoices.updateStatus.useMutation({
onSuccess: (data) => {
toast.success(data.message);
void utils.invoices.getById.invalidate({ id: invoiceId });
void utils.invoices.getAll.invalidate();
onStatusChange?.();
setIsChangingStatus(false);
},
onError: (error) => {
toast.error(error.message ?? "Failed to update status");
setIsChangingStatus(false);
},
});
const sendEmail = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
toast.success(data.message);
void utils.invoices.getById.invalidate({ id: invoiceId });
void utils.invoices.getAll.invalidate();
onStatusChange?.();
},
onError: (error) => {
toast.error(error.message);
},
});
const handleStatusUpdate = async (newStatus: StoredInvoiceStatus) => {
setIsChangingStatus(true);
updateStatus.mutate({
id: invoiceId,
status: newStatus,
});
};
const handleSendEmail = () => {
sendEmail.mutate({ invoiceId });
};
const effectiveStatus = getEffectiveInvoiceStatus(currentStatus, dueDate);
const isOverdue = isInvoiceOverdue(currentStatus, dueDate);
const daysPastDue = getDaysPastDue(currentStatus, dueDate);
const statusConfig = getStatusConfig(currentStatus, dueDate);
const StatusIcon = statusIconConfig[effectiveStatus];
const getAvailableActions = () => {
const actions = [];
switch (effectiveStatus) {
case "draft":
if (clientEmail) {
actions.push({
key: "send",
label: "Send Invoice",
action: handleSendEmail,
variant: "default" as const,
icon: Send,
disabled: sendEmail.isPending,
});
}
actions.push({
key: "markPaid",
label: "Mark as Paid",
action: () => handleStatusUpdate("paid"),
variant: "secondary" as const,
icon: DollarSign,
disabled: isChangingStatus,
});
break;
case "sent":
actions.push({
key: "markPaid",
label: "Mark as Paid",
action: () => handleStatusUpdate("paid"),
variant: "default" as const,
icon: DollarSign,
disabled: isChangingStatus,
});
if (clientEmail) {
actions.push({
key: "resend",
label: "Resend Invoice",
action: handleSendEmail,
variant: "outline" as const,
icon: Send,
disabled: sendEmail.isPending,
});
}
actions.push({
key: "backToDraft",
label: "Back to Draft",
action: () => handleStatusUpdate("draft"),
variant: "outline" as const,
icon: FileText,
disabled: isChangingStatus,
});
break;
case "overdue":
actions.push({
key: "markPaid",
label: "Mark as Paid",
action: () => handleStatusUpdate("paid"),
variant: "default" as const,
icon: DollarSign,
disabled: isChangingStatus,
});
if (clientEmail) {
actions.push({
key: "resend",
label: "Resend Invoice",
action: handleSendEmail,
variant: "outline" as const,
icon: Send,
disabled: sendEmail.isPending,
});
}
actions.push({
key: "backToSent",
label: "Mark as Sent",
action: () => handleStatusUpdate("sent"),
variant: "outline" as const,
icon: Clock,
disabled: isChangingStatus,
});
break;
case "paid":
// Paid invoices can be reverted if needed (rare cases)
actions.push({
key: "revert",
label: "Revert to Sent",
action: () => handleStatusUpdate("sent"),
variant: "outline" as const,
icon: RefreshCw,
disabled: isChangingStatus,
requireConfirmation: true,
});
break;
}
return actions;
};
const actions = getAvailableActions();
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<StatusIcon className="h-5 w-5" />
Invoice Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Status Display */}
<div className="flex items-center gap-3">
<Badge className={statusConfig.color} variant="secondary">
{statusConfig.label}
</Badge>
<span className="text-muted-foreground text-sm">
{statusConfig.description}
</span>
</div>
{/* Overdue Warning */}
{isOverdue && (
<div className="bg-destructive/10 text-destructive flex items-center gap-2 p-3">
<AlertCircle className="h-4 w-4" />
<span className="text-sm font-medium">
{daysPastDue} day{daysPastDue !== 1 ? "s" : ""} overdue
</span>
</div>
)}
{/* Due Date Info */}
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4" />
<span>
Due:{" "}
{new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(dueDate))}
</span>
</div>
{/* Action Buttons */}
{actions.length > 0 && (
<div className="space-y-2">
<div className="text-foreground text-sm font-medium">
Available Actions:
</div>
<div className="grid gap-2">
{actions.map((action) => {
const ActionIcon = action.icon;
if (action.requireConfirmation) {
return (
<AlertDialog key={action.key}>
<AlertDialogTrigger asChild>
<Button
variant={action.variant}
size="sm"
disabled={action.disabled}
className="w-full justify-start"
>
<ActionIcon className="mr-2 h-4 w-4" />
{action.label}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Confirm Status Change
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to change this invoice status?
This action may affect your records.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={action.action}>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
return (
<Button
key={action.key}
variant={action.variant}
size="sm"
onClick={action.action}
disabled={action.disabled}
className="w-full justify-start"
>
{action.disabled &&
(action.key === "send" || action.key === "resend") ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : action.disabled &&
(action.key === "markPaid" ||
action.key === "backToDraft" ||
action.key === "backToSent") ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ActionIcon className="mr-2 h-4 w-4" />
)}
{action.label}
</Button>
);
})}
</div>
</div>
)}
{/* No Email Warning */}
{!clientEmail && effectiveStatus !== "paid" && (
<div className="bg-muted text-muted-foreground p-3">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span className="text-sm font-medium">
No email address on file for this client
</span>
</div>
<p className="mt-1 text-xs">
Add an email address to the client to enable sending invoices.
</p>
</div>
)}
</CardContent>
</Card>
);
}
+46 -25
View File
@@ -45,7 +45,7 @@ export default async function BusinessDetailPage({
return (
<div className="space-y-6 pb-32">
<PageHeader
title={business.name}
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
description="View business details and information"
variant="gradient"
>
@@ -55,7 +55,7 @@ export default async function BusinessDetailPage({
<span>Back to Businesses</span>
</Link>
</Button>
<Button asChild className="btn-brand-primary shadow-md">
<Button asChild variant="default" className="shadow-md">
<Link href={`/dashboard/businesses/${business.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
<span>Edit Business</span>
@@ -66,11 +66,11 @@ export default async function BusinessDetailPage({
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Business Information Card */}
<div className="lg:col-span-2">
<Card className="card-primary">
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="bg-blue-subtle rounded-lg p-2">
<Building className="text-icon-blue h-5 w-5" />
<div className="bg-primary/10 p-2">
<Building className="text-primary h-5 w-5" />
</div>
<span>Business Information</span>
</CardTitle>
@@ -84,8 +84,8 @@ export default async function BusinessDetailPage({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{business.email && (
<div className="flex items-center space-x-3">
<div className="bg-green-subtle rounded-lg p-2">
<Mail className="text-icon-green h-4 w-4" />
<div className="bg-primary/10 p-2">
<Mail className="text-primary h-4 w-4" />
</div>
<div>
<p className="text-muted-foreground text-sm font-medium">
@@ -100,8 +100,8 @@ export default async function BusinessDetailPage({
{business.phone && (
<div className="flex items-center space-x-3">
<div className="bg-green-subtle rounded-lg p-2">
<Phone className="text-icon-green h-4 w-4" />
<div className="bg-primary/10 p-2">
<Phone className="text-primary h-4 w-4" />
</div>
<div>
<p className="text-muted-foreground text-sm font-medium">
@@ -116,8 +116,8 @@ export default async function BusinessDetailPage({
{business.website && (
<div className="flex items-center space-x-3">
<div className="bg-green-subtle rounded-lg p-2">
<Globe className="text-icon-green h-4 w-4" />
<div className="bg-primary/10 p-2">
<Globe className="text-primary h-4 w-4" />
</div>
<div>
<p className="text-muted-foreground text-sm font-medium">
@@ -137,8 +137,8 @@ export default async function BusinessDetailPage({
{business.taxId && (
<div className="flex items-center space-x-3">
<div className="bg-green-subtle rounded-lg p-2">
<Hash className="text-icon-green h-4 w-4" />
<div className="bg-primary/10 p-2">
<Hash className="text-primary h-4 w-4" />
</div>
<div>
<p className="text-muted-foreground text-sm font-medium">
@@ -162,8 +162,8 @@ export default async function BusinessDetailPage({
Business Address
</h3>
<div className="flex items-start space-x-3">
<div className="bg-green-subtle rounded-lg p-2">
<MapPin className="text-icon-green h-4 w-4" />
<div className="bg-primary/10 p-2">
<MapPin className="text-primary h-4 w-4" />
</div>
<div className="space-y-1 text-sm">
{business.addressLine1 && (
@@ -205,8 +205,8 @@ export default async function BusinessDetailPage({
<h3 className="mb-4 text-lg font-semibold">Business Details</h3>
<div className="space-y-4">
<div className="flex items-center space-x-3">
<div className="bg-green-subtle rounded-lg p-2">
<Calendar className="text-icon-green h-4 w-4" />
<div className="bg-primary/10 p-2">
<Calendar className="text-primary h-4 w-4" />
</div>
<div>
<p className="text-muted-foreground text-sm font-medium">
@@ -218,11 +218,32 @@ export default async function BusinessDetailPage({
</div>
</div>
{business.nickname && (
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<Building className="text-primary h-4 w-4" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-muted-foreground text-sm font-medium">
Nickname
</p>
<Badge variant="outline" className="text-xs">
Internal only
</Badge>
</div>
<p className="text-foreground text-sm">
{business.nickname}
</p>
</div>
</div>
)}
{/* Default Business Badge */}
{business.isDefault && (
<div className="flex items-center space-x-3">
<div className="bg-green-subtle rounded-lg p-2">
<Building className="text-icon-green h-4 w-4" />
<div className="bg-primary/10 p-2">
<Building className="text-primary h-4 w-4" />
</div>
<div>
<p className="text-muted-foreground text-sm font-medium">
@@ -230,7 +251,7 @@ export default async function BusinessDetailPage({
</p>
<Badge
variant="default"
className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
className="bg-primary/10 text-primary"
>
Default Business
</Badge>
@@ -245,11 +266,11 @@ export default async function BusinessDetailPage({
{/* Settings & Actions Card */}
<div className="space-y-6">
<Card className="card-primary">
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="bg-blue-subtle rounded-lg p-2">
<Building className="text-icon-blue h-5 w-5" />
<div className="bg-primary/10 p-2">
<Building className="text-primary h-5 w-5" />
</div>
<span>Quick Actions</span>
</CardTitle>
@@ -281,7 +302,7 @@ export default async function BusinessDetailPage({
</Card>
{/* Information Card */}
<Card className="card-primary">
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-lg">About This Business</CardTitle>
</CardHeader>
@@ -292,7 +313,7 @@ export default async function BusinessDetailPage({
represents your company information to clients.
</p>
{business.isDefault && (
<p className="text-green-600 dark:text-green-400">
<p className="text-primary">
This is your default business and will be automatically
selected when creating new invoices.
</p>
@@ -22,6 +22,7 @@ import { toast } from "sonner";
interface Business {
id: string;
name: string;
nickname: string | null;
email: string | null;
phone: string | null;
addressLine1: string | null;
@@ -42,17 +43,6 @@ interface BusinessesDataTableProps {
businesses: Business[];
}
const formatAddress = (business: Business) => {
const parts = [
business.addressLine1,
business.addressLine2,
business.city,
business.state,
business.postalCode,
].filter(Boolean);
return parts.join(", ") || "—";
};
export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
const router = useRouter();
const [businessToDelete, setBusinessToDelete] = useState<Business | null>(
@@ -61,6 +51,11 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
const utils = api.useUtils();
const searchableBusinesses = businesses.map((b) => ({
...b,
searchValue: `${b.name} ${b.nickname ?? ""}`.trim(),
}));
const deleteBusinessMutation = api.businesses.delete.useMutation({
onSuccess: () => {
toast.success("Business deleted successfully");
@@ -91,19 +86,30 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
const business = row.original;
return (
<div className="flex items-center gap-3">
<div className="bg-blue-subtle hidden rounded-lg p-2 sm:flex">
<Building className="text-icon-blue h-4 w-4" />
<div className="bg-primary/10 hidden p-2 sm:flex">
<Building className="text-primary h-4 w-4" />
</div>
<div className="min-w-0">
<p className="truncate font-medium">{business.name}</p>
<p className="text-muted-foreground truncate text-sm">
{business.email ?? "—"}
{business.nickname ?? "—"}
</p>
</div>
</div>
);
},
},
{
accessorKey: "email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Email" />
),
cell: ({ row }) => row.original.email ?? "—",
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
},
{
accessorKey: "phone",
header: ({ column }) => (
@@ -115,26 +121,6 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
cellClassName: "hidden md:table-cell",
},
},
{
id: "address",
header: "Address",
cell: ({ row }) => formatAddress(row.original),
meta: {
headerClassName: "hidden lg:table-cell",
cellClassName: "hidden lg:table-cell",
},
},
{
accessorKey: "taxId",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Tax ID" />
),
cell: ({ row }) => row.original.taxId ?? "—",
meta: {
headerClassName: "hidden xl:table-cell",
cellClassName: "hidden xl:table-cell",
},
},
{
accessorKey: "website",
header: ({ column }) => (
@@ -175,6 +161,15 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
);
},
},
{
accessorKey: "searchValue",
header: "Search",
cell: () => null,
meta: {
headerClassName: "hidden",
cellClassName: "hidden",
},
},
{
id: "actions",
cell: ({ row }) => {
@@ -210,9 +205,9 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
<>
<DataTable
columns={columns}
data={businesses}
searchKey="name"
searchPlaceholder="Search businesses..."
data={searchableBusinesses}
searchKey="searchValue"
searchPlaceholder="Search by name or nickname..."
onRowClick={handleRowClick}
/>
@@ -226,8 +221,8 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the
business "{businessToDelete?.name}" and remove all associated
data.
business &quot;{businessToDelete?.name}&quot; and remove all
associated data.
</DialogDescription>
</DialogHeader>
<DialogFooter>
+3 -13
View File
@@ -1,20 +1,10 @@
import Link from "next/link";
import { BusinessForm } from "~/components/forms/business-form";
import { PageHeader } from "~/components/layout/page-header";
import { HydrateClient } from "~/trpc/server";
export default function NewBusinessPage() {
return (
<div className="space-y-6 pb-32">
<PageHeader
title="Add Business"
description="Enter business details below to add a new business."
variant="gradient"
/>
<HydrateClient>
<BusinessForm mode="create" />
</HydrateClient>
</div>
<HydrateClient>
<BusinessForm mode="create" />
</HydrateClient>
);
}
+9 -9
View File
@@ -1,11 +1,11 @@
import { Plus } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { Plus, Building } from "lucide-react";
import { BusinessesDataTable } from "./_components/businesses-data-table";
import { PageHeader } from "~/components/layout/page-header";
import { DataTableSkeleton } from "~/components/data/data-table";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { api, HydrateClient } from "~/trpc/server";
import { BusinessesDataTable } from "./_components/businesses-data-table";
// Businesses Table Component
async function BusinessesTable() {
@@ -16,13 +16,13 @@ async function BusinessesTable() {
export default async function BusinessesPage() {
return (
<>
<div className="page-enter space-y-8">
<PageHeader
title="Businesses"
description="Manage your businesses and their information"
variant="gradient"
>
<Button asChild className="btn-brand-primary shadow-md">
<Button asChild variant="default" className="hover-lift shadow-md">
<Link href="/dashboard/businesses/new">
<Plus className="mr-2 h-5 w-5" />
<span>Add Business</span>
@@ -31,10 +31,10 @@ export default async function BusinessesPage() {
</PageHeader>
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={6} rows={5} />}>
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
<BusinessesTable />
</Suspense>
</HydrateClient>
</>
</div>
);
}
+60 -42
View File
@@ -15,6 +15,8 @@ import {
DollarSign,
ArrowLeft,
} from "lucide-react";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
interface ClientDetailPageProps {
params: Promise<{ id: string }>;
@@ -67,7 +69,7 @@ export default async function ClientDetailPage({
<span>Back to Clients</span>
</Link>
</Button>
<Button asChild className="btn-brand-primary shadow-md">
<Button asChild variant="default" className="shadow-md">
<Link href={`/dashboard/clients/${client.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
<span>Edit Client</span>
@@ -78,11 +80,11 @@ export default async function ClientDetailPage({
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Client Information Card */}
<div className="lg:col-span-2">
<Card className="card-primary">
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="bg-blue-subtle rounded-lg p-2">
<Building className="text-icon-blue h-5 w-5" />
<div className="bg-primary/10 p-2">
<Building className="text-primary h-5 w-5" />
</div>
<span>Contact Information</span>
</CardTitle>
@@ -92,8 +94,8 @@ export default async function ClientDetailPage({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{client.email && (
<div className="flex items-center space-x-3">
<div className="bg-green-subtle rounded-lg p-2">
<Mail className="text-icon-green h-4 w-4" />
<div className="bg-primary/10 p-2">
<Mail className="text-primary h-4 w-4" />
</div>
<div>
<p className="text-muted-foreground text-sm font-medium">
@@ -106,8 +108,8 @@ export default async function ClientDetailPage({
{client.phone && (
<div className="flex items-center space-x-3">
<div className="bg-green-subtle rounded-lg p-2">
<Phone className="text-icon-green h-4 w-4" />
<div className="bg-primary/10 p-2">
<Phone className="text-primary h-4 w-4" />
</div>
<div>
<p className="text-muted-foreground text-sm font-medium">
@@ -124,8 +126,8 @@ export default async function ClientDetailPage({
<div>
<h3 className="mb-4 text-lg font-semibold">Client Address</h3>
<div className="flex items-start space-x-3">
<div className="bg-green-subtle rounded-lg p-2">
<MapPin className="text-icon-green h-4 w-4" />
<div className="bg-primary/10 p-2">
<MapPin className="text-primary h-4 w-4" />
</div>
<div className="space-y-1 text-sm">
{client.addressLine1 && (
@@ -153,8 +155,8 @@ export default async function ClientDetailPage({
<div>
<h3 className="mb-4 text-lg font-semibold">Client Details</h3>
<div className="flex items-center space-x-3">
<div className="bg-green-subtle rounded-lg p-2">
<Calendar className="text-icon-green h-4 w-4" />
<div className="bg-primary/10 p-2">
<Calendar className="text-primary h-4 w-4" />
</div>
<div>
<p className="text-muted-foreground text-sm font-medium">
@@ -172,11 +174,11 @@ export default async function ClientDetailPage({
{/* Stats Card */}
<div className="space-y-6">
<Card className="card-primary">
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="bg-blue-subtle rounded-lg p-2">
<DollarSign className="text-icon-blue h-5 w-5" />
<div className="bg-primary/10 p-2">
<DollarSign className="text-primary h-5 w-5" />
</div>
<span>Invoice Summary</span>
</CardTitle>
@@ -211,8 +213,8 @@ export default async function ClientDetailPage({
<Card className="">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="bg-blue-subtle rounded-lg p-2">
<DollarSign className="text-icon-blue h-5 w-5" />
<div className="bg-primary/10 p-2">
<DollarSign className="text-primary h-5 w-5" />
</div>
<span>Recent Invoices</span>
</CardTitle>
@@ -222,32 +224,48 @@ export default async function ClientDetailPage({
{client.invoices.slice(0, 3).map((invoice) => (
<div
key={invoice.id}
className="card-secondary transition-colors hover:bg-gray-200/70 dark:hover:bg-gray-700/60 flex items-center justify-between rounded-lg border p-3"
className="card-secondary hover:bg-muted/50 border p-3 transition-colors"
>
<div>
<p className="text-foreground font-medium">
{invoice.invoiceNumber}
</p>
<p className="text-muted-foreground text-sm">
{formatDate(invoice.issueDate)}
</p>
</div>
<div className="text-right">
<p className="text-foreground font-semibold">
{formatCurrency(invoice.totalAmount)}
</p>
<Badge
variant={
invoice.status === "paid"
? "default"
: invoice.status === "sent"
? "secondary"
: "outline"
}
className="text-xs"
>
{invoice.status}
</Badge>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-foreground font-medium break-words">
{invoice.invoiceNumber}
</p>
<p className="text-muted-foreground text-sm">
{formatDate(invoice.issueDate)}
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2 self-start sm:flex-col sm:items-end sm:gap-1">
<p className="text-foreground font-semibold">
{formatCurrency(invoice.totalAmount)}
</p>
<Badge
variant={
getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) === "paid"
? "default"
: getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) === "sent"
? "secondary"
: getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) === "overdue"
? "destructive"
: "outline"
}
className="text-xs"
>
{getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
)}
</Badge>
</div>
</div>
</div>
))}
@@ -90,13 +90,13 @@ export function ClientsDataTable({
const client = row.original;
return (
<div className="flex items-center gap-3">
<div className="bg-status-info-muted hidden rounded-lg p-2 sm:flex">
<UserPlus className="text-status-info h-4 w-4" />
<div className="bg-primary/10 hidden p-2 sm:flex">
<UserPlus className="text-primary h-4 w-4" />
</div>
<div className="min-w-0">
<p className="truncate font-medium">{client.name}</p>
<p className="text-muted-foreground truncate text-sm">
{client.email || "—"}
{client.email ?? "—"}
</p>
</div>
</div>
@@ -108,7 +108,7 @@ export function ClientsDataTable({
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Phone" />
),
cell: ({ row }) => row.original.phone || "—",
cell: ({ row }) => row.original.phone ?? "—",
meta: {
headerClassName: "hidden md:table-cell",
cellClassName: "hidden md:table-cell",
@@ -192,7 +192,8 @@ export function ClientsDataTable({
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the
client "{clientToDelete?.name}" and remove all associated data.
client &quot;{clientToDelete?.name}&quot; and remove all
associated data.
</DialogDescription>
</DialogHeader>
<DialogFooter>
+7 -8
View File
@@ -1,20 +1,19 @@
import Link from "next/link";
import { HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { Plus } from "lucide-react";
import { ClientsTable } from "./_components/clients-table";
import Link from "next/link";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent, PageSection } from "~/components/layout/page-layout";
import { Button } from "~/components/ui/button";
import { HydrateClient } from "~/trpc/server";
import { ClientsTable } from "./_components/clients-table";
export default async function ClientsPage() {
return (
<>
<div className="page-enter space-y-6">
<PageHeader
title="Clients"
description="Manage your clients and their information."
variant="gradient"
>
<Button asChild className="btn-brand-primary shadow-md">
<Button asChild variant="default" className="hover-lift shadow-md">
<Link href="/dashboard/clients/new">
<Plus className="mr-2 h-5 w-5" />
<span>Add Client</span>
@@ -25,6 +24,6 @@ export default async function ClientsPage() {
<HydrateClient>
<ClientsTable />
</HydrateClient>
</>
</div>
);
}
+288
View File
@@ -0,0 +1,288 @@
"use client";
import { useState } from "react";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Checkbox } from "~/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { DatePicker } from "~/components/ui/date-picker";
import { NumberInput } from "~/components/ui/number-input";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
interface ExpenseFormData {
date: Date;
description: string;
amount: number;
currency: string;
category: string;
billable: boolean;
reimbursable: boolean;
taxDeductible: boolean;
notes: string;
clientId: string;
}
const defaultForm: ExpenseFormData = {
date: new Date(),
description: "",
amount: 0,
currency: "USD",
category: "",
billable: false,
reimbursable: false,
taxDeductible: false,
notes: "",
clientId: "",
};
export default function ExpensesPage() {
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const utils = api.useUtils();
const { data: expenses = [], isLoading } = api.expenses.getAll.useQuery();
const { data: clients = [] } = api.clients.getAll.useQuery();
const create = api.expenses.create.useMutation({
onSuccess: () => { toast.success("Expense added"); void utils.expenses.getAll.invalidate(); setOpen(false); setForm(defaultForm); },
onError: (e) => toast.error(e.message),
});
const update = api.expenses.update.useMutation({
onSuccess: () => { toast.success("Expense updated"); void utils.expenses.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); },
onError: (e) => toast.error(e.message),
});
const del = api.expenses.delete.useMutation({
onSuccess: () => { toast.success("Expense deleted"); void utils.expenses.getAll.invalidate(); setDeleteId(null); },
onError: (e) => toast.error(e.message),
});
const handleOpen = () => { setEditId(null); setForm(defaultForm); setOpen(true); };
const handleEdit = (expense: typeof expenses[0]) => {
setEditId(expense.id);
setForm({
date: new Date(expense.date),
description: expense.description,
amount: expense.amount,
currency: expense.currency,
category: expense.category ?? "",
billable: expense.billable,
reimbursable: expense.reimbursable,
taxDeductible: expense.taxDeductible ?? false,
notes: expense.notes ?? "",
clientId: expense.clientId ?? "",
});
setOpen(true);
};
const handleSubmit = () => {
if (!form.description.trim()) { toast.error("Description is required"); return; }
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; }
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined, taxDeductible: form.taxDeductible };
if (editId) update.mutate({ id: editId, ...payload });
else create.mutate(payload);
};
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0);
const deductibleTotal = expenses.filter((e) => e.taxDeductible).reduce((s, e) => s + e.amount, 0);
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader title="Expenses" description="Track billable and non-billable expenses" variant="gradient">
<Button onClick={handleOpen} variant="default" className="hover-lift shadow-md">
<Plus className="mr-2 h-5 w-5" /> Add Expense
</Button>
</PageHeader>
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
<p className="mt-1 text-2xl font-bold">{formatCurrency(totalExpenses)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Billable</p>
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Deductible</p>
<p className="mt-1 text-2xl font-bold text-green-600">{formatCurrency(deductibleTotal)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
</CardContent>
</Card>
</div>
{/* Expenses list */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" /> All Expenses
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">Loading</div>
) : expenses.length === 0 ? (
<div className="p-8 text-center">
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground text-sm">No expenses yet. Add your first expense.</p>
</div>
) : (
<div className="divide-y">
{expenses.map((expense) => (
<div key={expense.id} className="flex items-start justify-between gap-3 p-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{expense.description}</p>
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>}
{expense.taxDeductible && <Badge variant="outline" className="text-xs text-green-600 border-green-300">Tax Deductible</Badge>}
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>}
</div>
<p className="text-muted-foreground mt-0.5 text-xs">
{new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(expense.date))}
{expense.client ? ` · ${expense.client.name}` : ""}
</p>
{expense.notes && <p className="text-muted-foreground mt-1 text-xs">{expense.notes}</p>}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<p className="font-semibold">{formatCurrency(expense.amount, expense.currency)}</p>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(expense)}><Pencil className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(expense.id)}><Trash2 className="h-3.5 w-3.5" /></Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Add/Edit dialog */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Description *</Label>
<Input value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} placeholder="e.g. Laptop charger" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Amount *</Label>
<NumberInput value={form.amount} onChange={(v) => setForm((p) => ({ ...p, amount: v }))} min={0} step={0.01} />
</div>
<div className="space-y-2">
<Label>Currency</Label>
<Select value={form.currency} onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{SUPPORTED_CURRENCIES.map((c) => <SelectItem key={c.code} value={c.code}>{c.code}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Date</Label>
<DatePicker date={form.date} onDateChange={(d) => setForm((p) => ({ ...p, date: d ?? new Date() }))} className="w-full" />
</div>
<div className="space-y-2">
<Label>Category</Label>
<Select value={form.category || "none"} onValueChange={(v) => setForm((p) => ({ ...p, category: v === "none" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{EXPENSE_CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Client (optional)</Label>
<Select value={form.clientId || "none"} onValueChange={(v) => setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="No client" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">No client</SelectItem>
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap gap-6">
<label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
<span className="text-sm">Billable</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
<span className="text-sm">Reimbursable</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.taxDeductible} onCheckedChange={(v) => setForm((p) => ({ ...p, taxDeductible: !!v }))} />
<span className="text-sm">Tax Deductible</span>
</label>
</div>
<div className="space-y-2">
<Label>Notes (optional)</Label>
<Input value={form.notes} onChange={(e) => setForm((p) => ({ ...p, notes: e.target.value }))} placeholder="Additional details…" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Add Expense"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete dialog */}
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Expense</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button>
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}>
{del.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,44 +1,45 @@
import { Card, CardContent, CardHeader } from "~/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import { Skeleton } from "~/components/ui/skeleton";
import { PageHeader } from "~/components/layout/page-header";
export function InvoiceDetailsSkeleton() {
return (
<div className="space-y-6 pb-24">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<Skeleton className="bg-muted/30 h-8 w-48 sm:h-9 sm:w-64" />
<Skeleton className="bg-muted/30 mt-1 h-4 w-40 sm:w-48" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="bg-muted/30 h-8 w-20 sm:h-9 sm:w-24" />
<Skeleton className="bg-muted/30 h-8 w-16 sm:h-9 sm:w-20" />
</div>
</div>
<PageHeader
title="Loading..."
description="View and manage invoice information"
variant="gradient"
>
<Skeleton className="h-10 w-10 sm:w-32" />
<Skeleton className="h-10 w-24" />
</PageHeader>
{/* Content */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left Column */}
<div className="space-y-6 lg:col-span-2">
{/* Invoice Header Skeleton */}
<Card className="card-primary">
<Card>
<CardContent className="p-4 sm:p-6">
<div className="space-y-4">
<div className="flex items-start justify-between gap-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Skeleton className="bg-muted/30 h-6 w-40 sm:h-8 sm:w-48" />
<Skeleton className="bg-muted/30 h-5 w-16 sm:h-6" />
<Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
<div className="space-y-1 sm:space-y-0">
<Skeleton className="bg-muted/30 h-3 w-32 sm:h-4 sm:w-40" />
<Skeleton className="bg-muted/30 h-3 w-28 sm:hidden sm:h-4 sm:w-36" />
<div className="space-y-1 text-sm sm:space-y-0">
<div className="flex gap-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="hidden h-4 w-32 sm:block" />
</div>
</div>
</div>
<div className="flex-shrink-0 text-right">
<Skeleton className="bg-muted/30 h-3 w-20 sm:h-4" />
<Skeleton className="bg-muted/30 mt-1 h-6 w-24 sm:h-8 sm:w-28" />
<div className="flex-shrink-0 text-left sm:text-right">
<Skeleton className="mb-1 h-4 w-24 sm:ml-auto" />
<Skeleton className="h-9 w-32 sm:ml-auto" />
</div>
</div>
</div>
@@ -47,105 +48,126 @@ export function InvoiceDetailsSkeleton() {
{/* Client & Business Info */}
<div className="grid gap-4 sm:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i} className="card-primary">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Skeleton className="bg-muted/30 h-4 w-4 sm:h-5 sm:w-5" />
<Skeleton className="bg-muted/30 h-5 w-16 sm:h-6" />
{/* Client Skeleton */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-5 w-16" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-7 w-48" />
<div className="space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-4 w-40" />
</div>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="bg-muted/30 h-5 w-32 sm:h-6" />
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, j) => (
<div key={j} className="flex items-center gap-3">
<Skeleton className="bg-muted/30 h-8 w-8 rounded-lg" />
<Skeleton className="bg-muted/30 h-4 w-28" />
</div>
))}
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-md" />
<div className="space-y-1">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</div>
</div>
</CardContent>
</Card>
{/* Business Skeleton */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-5 w-16" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-7 w-48" />
<div className="space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-4 w-40" />
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-4 w-32" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Invoice Items Skeleton */}
<Card className="card-primary">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="bg-muted/30 h-4 w-4 sm:h-5 sm:w-5" />
<Skeleton className="bg-muted/30 h-5 w-28 sm:h-6" />
</div>
<CardTitle className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-5 w-32" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Item Rows */}
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-3 rounded-lg border p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<Skeleton className="bg-muted/30 mb-2 h-4 w-full sm:h-5 sm:w-3/4" />
<div className="space-y-1 sm:space-y-0">
<Skeleton className="bg-muted/30 h-3 w-20 sm:h-4 sm:w-24" />
<Skeleton className="bg-muted/30 h-3 w-16 sm:hidden sm:h-4 sm:w-20" />
<Skeleton className="bg-muted/30 h-3 w-24 sm:hidden sm:h-4 sm:w-28" />
<Card key={i} className="bg-secondary/50 border-0">
<CardContent className="p-3">
<div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<Skeleton className="mb-2 h-5 w-3/4" />
<div className="flex gap-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-20" />
</div>
</div>
<Skeleton className="h-6 w-24" />
</div>
</div>
<div className="flex-shrink-0 text-right">
<Skeleton className="bg-muted/30 h-4 w-16 sm:h-5 sm:w-20" />
</div>
</div>
</div>
</CardContent>
</Card>
))}
{/* Totals */}
<div className="bg-muted/30 rounded-lg p-4">
<div className="bg-secondary rounded-lg p-4">
<div className="space-y-3">
<div className="flex justify-between">
<Skeleton className="bg-muted/30 h-4 w-16" />
<Skeleton className="bg-muted/30 h-4 w-20" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex justify-between">
<Skeleton className="bg-muted/30 h-4 w-20" />
<Skeleton className="bg-muted/30 h-4 w-16" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-24" />
</div>
<Separator />
<div className="flex justify-between">
<Skeleton className="bg-muted/30 h-5 w-12" />
<Skeleton className="bg-muted/30 h-5 w-24" />
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-32" />
</div>
</div>
</div>
</CardContent>
</Card>
{/* Notes */}
<Card className="card-primary">
<CardHeader>
<Skeleton className="bg-muted/30 h-6 w-16" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<Skeleton className="bg-muted/30 h-4 w-full" />
<Skeleton className="bg-muted/30 h-4 w-3/4" />
<Skeleton className="bg-muted/30 h-4 w-1/2" />
</div>
</CardContent>
</Card>
</div>
{/* Right Column - Actions */}
<div className="space-y-6">
<Card className="card-primary sticky top-6">
<Card className="lg:sticky lg:top-6">
<CardHeader>
<div className="flex items-center gap-2">
<Skeleton className="bg-muted/30 h-5 w-5" />
<Skeleton className="bg-muted/30 h-6 w-16" />
</div>
<CardTitle className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-5 w-24" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="bg-muted/30 h-10 w-full" />
))}
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
@@ -40,13 +40,32 @@ const columns: ColumnDef<InvoiceItem>[] = [
accessorKey: "date",
header: "Date",
cell: ({ row }) => formatDate(row.getValue("date")),
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<div className="font-medium">{row.getValue("description")}</div>
),
cell: ({ row }) => {
const item = row.original;
return (
<>
{/* Desktop: plain description */}
<div className="hidden font-medium sm:block">
{item.description}
</div>
{/* Mobile: description + date + hours @ rate stacked */}
<div className="sm:hidden">
<p className="font-medium">{item.description}</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{formatDate(item.date)} &middot; {item.hours}h @ {formatCurrency(item.rate)}/hr
</p>
</div>
</>
);
},
},
{
accessorKey: "hours",
@@ -54,6 +73,10 @@ const columns: ColumnDef<InvoiceItem>[] = [
cell: ({ row }) => (
<div className="text-right">{row.getValue("hours")}</div>
),
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
},
{
accessorKey: "rate",
@@ -61,12 +84,16 @@ const columns: ColumnDef<InvoiceItem>[] = [
cell: ({ row }) => (
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
),
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
},
{
accessorKey: "amount",
header: "Amount",
cell: ({ row }) => (
<div className="text-icon-emerald text-right font-medium">
<div className="text-primary text-right font-medium">
{formatCurrency(row.getValue("amount"))}
</div>
),
@@ -9,7 +9,7 @@ import { Download, Loader2 } from "lucide-react";
interface PDFDownloadButtonProps {
invoiceId: string;
variant?: "default" | "outline" | "ghost" | "icon";
variant?: "default" | "outline" | "ghost" | "icon" | "secondary";
className?: string;
}
@@ -25,6 +25,9 @@ export function PDFDownloadButton({
{ id: invoiceId },
{ enabled: false },
);
const { data: platformTheme } = api.settings.getTheme.useQuery(undefined, {
staleTime: 60_000,
});
const handleDownloadPDF = async () => {
if (isGenerating) return;
@@ -39,7 +42,29 @@ export function PDFDownloadButton({
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");
} catch (error) {
console.error("PDF generation error:", error);
@@ -77,12 +102,12 @@ export function PDFDownloadButton({
>
{isGenerating ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
<span>Generating PDF...</span>
</>
) : (
<>
<Download className="h-5 w-5" />
<Download className="mr-2 h-5 w-5" />
<span>Download PDF</span>
</>
)}
@@ -1,162 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { Send, Loader2 } from "lucide-react";
interface SendInvoiceButtonProps {
invoiceId: string;
variant?: "default" | "outline" | "ghost" | "icon";
className?: string;
}
export function SendInvoiceButton({
invoiceId,
variant = "outline",
className,
}: SendInvoiceButtonProps) {
const [isSending, setIsSending] = useState(false);
// Fetch invoice data when sending is triggered
const { refetch: fetchInvoice } = api.invoices.getById.useQuery(
{ id: invoiceId },
{ enabled: false },
);
const handleSendInvoice = async () => {
if (isSending) return;
setIsSending(true);
try {
// Fetch fresh invoice data
const { data: invoice } = await fetchInvoice();
if (!invoice) {
throw new Error("Invoice not found");
}
// Generate PDF blob for potential attachment
const pdfBlob = await generateInvoicePDFBlob(invoice);
// Create a temporary download URL for the PDF
const pdfUrl = URL.createObjectURL(pdfBlob);
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
// Format date
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
};
// Calculate days until due
const today = new Date();
const dueDate = new Date(invoice.dueDate);
const daysUntilDue = Math.ceil(
(dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
);
// Create professional email template
const subject = `Invoice ${invoice.invoiceNumber} - ${formatCurrency(invoice.totalAmount)}`;
const body = `Dear ${invoice.client.name},
I hope this email finds you well. Please find attached invoice ${invoice.invoiceNumber} for the services provided.
Invoice Details:
• Invoice Number: ${invoice.invoiceNumber}
• Issue Date: ${formatDate(invoice.issueDate)}
• Due Date: ${formatDate(invoice.dueDate)}
• Amount Due: ${formatCurrency(invoice.totalAmount)}
${daysUntilDue > 0 ? `• Payment Due: In ${daysUntilDue} days` : daysUntilDue === 0 ? `• Payment Due: Today` : `• Status: ${Math.abs(daysUntilDue)} days overdue`}
${invoice.notes ? `\nAdditional Notes:\n${invoice.notes}\n` : ""}
Please review the attached invoice and remit payment by the due date. If you have any questions or concerns regarding this invoice, please don't hesitate to contact me.
Thank you for your business!
Best regards,
${invoice.business?.name ?? "Your Business Name"}
${invoice.business?.email ? `\n${invoice.business.email}` : ""}
${invoice.business?.phone ? `\n${invoice.business.phone}` : ""}`;
// Create mailto link
const mailtoLink = `mailto:${invoice.client.email ?? ""}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
// Create a temporary link element to trigger mailto
const link = document.createElement("a");
link.href = mailtoLink;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the PDF URL object
URL.revokeObjectURL(pdfUrl);
toast.success("Email client opened with invoice details");
} catch (error) {
console.error("Send invoice error:", error);
toast.error(
error instanceof Error
? error.message
: "Failed to prepare invoice email",
);
} finally {
setIsSending(false);
}
};
if (variant === "icon") {
return (
<Button
onClick={handleSendInvoice}
disabled={isSending}
variant="ghost"
size="sm"
className={className}
>
{isSending ? (
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
) : (
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
)}
</Button>
);
}
return (
<Button
onClick={handleSendInvoice}
disabled={isSending}
variant={variant}
size="default"
className={`w-full shadow-sm ${className}`}
data-testid="send-invoice-button"
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Preparing Email...</span>
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
<span>Send Invoice</span>
</>
)}
</Button>
);
}
@@ -1,26 +0,0 @@
"use client";
import { InvoiceView } from "~/components/data/invoice-view";
import { InvoiceForm } from "~/components/forms/invoice-form";
interface UnifiedInvoicePageProps {
invoiceId: string;
mode: string;
}
export function UnifiedInvoicePage({
invoiceId,
mode,
}: UnifiedInvoicePageProps) {
return (
<div>
{/* Always render InvoiceForm to preserve state, but hide when in view mode */}
<div className={mode === "edit" ? "block" : "hidden"}>
<InvoiceForm invoiceId={invoiceId} />
</div>
{/* Show InvoiceView only when in view mode */}
{mode === "view" && <InvoiceView invoiceId={invoiceId} />}
</div>
);
}
@@ -1,11 +1,12 @@
"use client";
import { useParams } from "next/navigation";
import { InvoiceForm } from "~/components/forms/invoice-form";
import InvoiceForm from "~/components/forms/invoice-form";
export default function EditInvoicePage() {
export default function InvoiceFormPage() {
const params = useParams();
const invoiceId = params.id as string;
const id = params.id as string;
return <InvoiceForm invoiceId={invoiceId} />;
// Pass the actual id, let the form component handle the logic
return <InvoiceForm invoiceId={id} />;
}
+241 -87
View File
@@ -1,34 +1,91 @@
import { Suspense } from "react";
import { notFound } from "next/navigation";
"use client";
import { DollarSign, Edit, Loader2, Trash2 } from "lucide-react";
import Link from "next/link";
import { api, HydrateClient } from "~/trpc/server";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { notFound, useParams, useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
import { Separator } from "~/components/ui/separator";
import { PageHeader } from "~/components/layout/page-header";
import { PDFDownloadButton } from "./_components/pdf-download-button";
import { SendInvoiceButton } from "./_components/send-invoice-button";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Separator } from "~/components/ui/separator";
import {
getEffectiveInvoiceStatus,
isInvoiceOverdue,
} from "~/lib/invoice-status";
import { api } from "~/trpc/react";
import type { StoredInvoiceStatus } from "~/types/invoice";
import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
import { PDFDownloadButton } from "./_components/pdf-download-button";
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
import {
AlertTriangle,
Building,
Edit,
Check,
FileText,
Mail,
MapPin,
Phone,
User,
AlertTriangle,
Check,
} from "lucide-react";
interface InvoicePageProps {
params: Promise<{ id: string }>;
}
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
const invoice = await api.invoices.getById({ id: invoiceId });
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
id: invoiceId,
});
const utils = api.useUtils();
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
toast.success("Invoice deleted successfully");
router.push("/dashboard/invoices");
},
onError: (error) => {
toast.error(error.message ?? "Failed to delete invoice");
},
});
const updateStatus = api.invoices.updateStatus.useMutation({
onSuccess: (data) => {
toast.success(data.message);
void utils.invoices.getById.invalidate({ id: invoiceId });
},
onError: (error) => {
toast.error(error.message ?? "Failed to update invoice status");
},
});
const handleDelete = () => {
setDeleteDialogOpen(true);
};
const handleMarkAsPaid = () => {
updateStatus.mutate({
id: invoiceId,
status: "paid" as StoredInvoiceStatus,
});
};
const confirmDelete = () => {
deleteInvoice.mutate({ id: invoiceId });
};
if (isLoading) {
return <InvoiceDetailsSkeleton />;
}
if (!invoice) {
notFound();
@@ -42,40 +99,44 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
}).format(new Date(date));
};
const formatCurrency = (amount: number) => {
const formatCurrency = (amount: number, currency = invoice.currency) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
currency,
}).format(amount);
};
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = (subtotal * invoice.taxRate) / 100;
const total = subtotal + taxAmount;
const isOverdue =
new Date(invoice.dueDate) < new Date() && invoice.status !== "paid";
const effectiveStatus = getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
const isOverdue = isInvoiceOverdue(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
const getStatusType = (): StatusType => {
if (invoice.status === "paid") return "paid";
if (invoice.status === "draft") return "draft";
if (invoice.status === "overdue") return "overdue";
if (invoice.status === "sent") {
return isOverdue ? "overdue" : "sent";
}
return "draft";
return effectiveStatus as StatusType;
};
return (
<div className="space-y-6 pb-24">
<div className="page-enter space-y-6 pb-24">
<PageHeader
title="Invoice Details"
description="View and manage invoice information"
variant="gradient"
>
<PDFDownloadButton invoiceId={invoice.id} variant="outline" />
<Button asChild variant="default">
<PDFDownloadButton
invoiceId={invoice.id}
variant="outline"
className="hover-lift"
/>
<Button asChild variant="default" className="hover-lift">
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Edit className="h-5 w-5" />
<Edit className="mr-2 h-5 w-5" />
<span>Edit</span>
</Link>
</Button>
@@ -86,13 +147,13 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
{/* Left Column */}
<div className="space-y-6 lg:col-span-2">
{/* Invoice Header */}
<Card className="card-primary">
<Card>
<CardContent className="p-4 sm:p-6">
<div className="space-y-4">
<div className="flex items-start justify-between gap-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<h2 className="text-foreground truncate text-2xl font-bold">
<h2 className="text-foreground text-2xl font-bold break-words">
{invoice.invoiceNumber}
</h2>
<StatusBadge status={getStatusType()} />
@@ -106,7 +167,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
</div>
</div>
</div>
<div className="flex-shrink-0 text-right">
<div className="flex-shrink-0 text-left sm:text-right">
<p className="text-muted-foreground text-sm">
Total Amount
</p>
@@ -121,7 +182,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
{/* Overdue Alert */}
{isOverdue && (
<Card className="border-destructive/20 bg-destructive/5 card-secondary">
<Card className="border-destructive/20 bg-destructive/5">
<CardContent className="p-4">
<div className="text-destructive flex items-center gap-3">
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
@@ -144,7 +205,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
{/* Client & Business Info */}
<div className="grid gap-4 sm:grid-cols-2">
{/* Client Information */}
<Card className="card-primary">
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
@@ -161,7 +222,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
<div className="space-y-3">
{invoice.client.email && (
<div className="flex items-center gap-3">
<div className="bg-primary/10 rounded-lg p-2">
<div className="bg-primary/10 p-2">
<Mail className="text-primary h-4 w-4" />
</div>
<span className="text-sm break-all">
@@ -172,7 +233,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
{invoice.client.phone && (
<div className="flex items-center gap-3">
<div className="bg-primary/10 rounded-lg p-2">
<div className="bg-primary/10 p-2">
<Phone className="text-primary h-4 w-4" />
</div>
<span className="text-sm">{invoice.client.phone}</span>
@@ -181,7 +242,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
{(invoice.client.addressLine1 ?? invoice.client.city) && (
<div className="flex items-start gap-3">
<div className="bg-primary/10 rounded-lg p-2">
<div className="bg-primary/10 p-2">
<MapPin className="text-primary h-4 w-4" />
</div>
<div className="space-y-1 text-sm">
@@ -216,7 +277,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
{/* Business Information */}
{invoice.business && (
<Card className="card-primary">
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<Building className="h-5 w-5" />
@@ -233,7 +294,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
<div className="space-y-3">
{invoice.business.email && (
<div className="flex items-center gap-3">
<div className="bg-primary/10 rounded-lg p-2">
<div className="bg-primary/10 p-2">
<Mail className="text-primary h-4 w-4" />
</div>
<span className="text-sm break-all">
@@ -244,7 +305,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
{invoice.business.phone && (
<div className="flex items-center gap-3">
<div className="bg-primary/10 rounded-lg p-2">
<div className="bg-primary/10 p-2">
<Phone className="text-primary h-4 w-4" />
</div>
<span className="text-sm">
@@ -259,7 +320,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
</div>
{/* Invoice Items */}
<Card className="card-primary">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
@@ -267,31 +328,35 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{invoice.items.map((item) => (
<Card key={item.id} className="card-secondary">
<CardContent className="py-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-foreground mb-2 text-base font-medium">
{item.description}
</p>
<div className="text-muted-foreground text-sm">
<span className="inline whitespace-nowrap">
{formatDate(item.date).replace(/ /g, "\u00A0")}
</span>
<span className="inline whitespace-nowrap before:mx-2 before:content-['_|_']">
{item.hours.toString().replace(/ /g, "\u00A0")}
&nbsp;hours
</span>
<span className="inline whitespace-nowrap before:mx-2 before:content-['_|_']">
@&nbsp;${item.rate}/hr
</span>
{invoice.items.map((item, _index) => (
<Card key={item.id} className="invoice-item bg-secondary">
<CardContent className="p-3">
<div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<p className="text-foreground mb-2 text-base font-medium break-words">
{item.description}
</p>
<div className="text-muted-foreground text-sm">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<span className="whitespace-nowrap">
{formatDate(item.date).replace(/ /g, "\u00A0")}
</span>
<span className="whitespace-nowrap">
{item.hours.toString().replace(/ /g, "\u00A0")}
&nbsp;hours
</span>
<span className="whitespace-nowrap">
@&nbsp;${item.rate}/hr
</span>
</div>
</div>
</div>
<div className="flex-shrink-0 self-start">
<p className="text-primary text-lg font-semibold">
{formatCurrency(item.amount)}
</p>
</div>
</div>
<div className="flex-shrink-0 text-right">
<p className="text-primary text-lg font-semibold">
{formatCurrency(item.amount)}
</p>
</div>
</div>
</CardContent>
@@ -299,16 +364,16 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
))}
{/* Totals */}
<div className="bg-muted/30 rounded-lg p-4">
<div className="bg-secondary rounded-lg p-4">
<div className="space-y-3">
<div className="flex justify-between">
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
<span className="text-muted-foreground">Subtotal:</span>
<span className="font-medium">
{formatCurrency(subtotal)}
</span>
</div>
{invoice.taxRate > 0 && (
<div className="flex justify-between">
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
<span className="text-muted-foreground">
Tax ({invoice.taxRate}%):
</span>
@@ -318,7 +383,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
</div>
)}
<Separator />
<div className="flex justify-between text-lg font-bold">
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-lg font-bold">
<span>Total:</span>
<span className="text-primary">
{formatCurrency(total)}
@@ -331,7 +396,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
{/* Notes */}
{invoice.notes && (
<Card className="card-primary">
<Card>
<CardHeader>
<CardTitle>Notes</CardTitle>
</CardHeader>
@@ -346,7 +411,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
{/* Right Column - Actions */}
<div className="space-y-6">
<Card className="card-primary sticky top-6">
<Card className="lg:sticky lg:top-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Check className="h-5 w-5" />
@@ -354,7 +419,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Button asChild variant="outline" className="w-full">
<Button asChild variant="secondary" className="w-full">
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Invoice
@@ -362,28 +427,117 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
</Button>
{invoice.items && invoice.client && (
<PDFDownloadButton invoiceId={invoice.id} className="w-full" />
<PDFDownloadButton
invoiceId={invoice.id}
className="w-full"
variant="secondary"
/>
)}
{invoice.status === "draft" && (
<SendInvoiceButton invoiceId={invoice.id} className="w-full" />
{/* Send Invoice Button - Show for draft, sent, and overdue */}
{effectiveStatus === "draft" && (
<EnhancedSendInvoiceButton
invoiceId={invoice.id}
className="w-full"
variant="secondary"
/>
)}
{(effectiveStatus === "sent" ||
effectiveStatus === "overdue") && (
<EnhancedSendInvoiceButton
invoiceId={invoice.id}
className="w-full"
showResend={true}
variant="secondary"
/>
)}
{/* Manual Status Updates */}
{(effectiveStatus === "sent" ||
effectiveStatus === "overdue") && (
<Button
onClick={handleMarkAsPaid}
disabled={updateStatus.isPending}
variant="secondary"
className="w-full"
>
{updateStatus.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<DollarSign className="mr-2 h-4 w-4" />
)}
Mark as Paid
</Button>
)}
<Button
variant="secondary"
onClick={handleDelete}
disabled={deleteInvoice.isPending}
className="text-destructive hover:bg-destructive/10 w-full"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Invoice
</Button>
</CardContent>
</Card>
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Invoice</DialogTitle>
<DialogDescription>
Are you sure you want to delete invoice{" "}
<strong>{invoice.invoiceNumber}</strong>? This action cannot be
undone and will permanently remove the invoice and all its data.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={deleteInvoice.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={deleteInvoice.isPending}
>
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default async function InvoicePage({ params }: InvoicePageProps) {
const { id } = await params;
export default function InvoiceViewPage() {
const params = useParams();
const router = useRouter();
const id = params.id as string;
return (
<HydrateClient>
<Suspense fallback={<InvoiceDetailsSkeleton />}>
<InvoiceContent invoiceId={id} />
</Suspense>
</HydrateClient>
);
// Handle /invoices/new route - redirect to dedicated new page
useEffect(() => {
if (id === "new") {
router.replace("/dashboard/invoices/new");
}
}, [id, router]);
// Don't render anything if we're redirecting
if (id === "new") {
return (
<div className="flex h-96 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
}
return <InvoiceViewContent invoiceId={id} />;
}
@@ -0,0 +1,649 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Label } from "~/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { PageHeader } from "~/components/layout/page-header";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { EmailComposer } from "~/components/forms/email-composer";
import { EmailPreview } from "~/components/forms/email-preview";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import {
Mail,
Send,
Eye,
Edit3,
AlertTriangle,
ArrowLeft,
Loader2,
FileText,
} from "lucide-react";
function SendEmailPageSkeleton() {
return (
<div className="space-y-6 pb-32">
<PageHeader
title="Loading..."
description="Loading invoice email"
variant="gradient"
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<div className="bg-muted h-96 animate-pulse" />
</div>
<div className="space-y-6">
<div className="bg-muted h-64 animate-pulse" />
</div>
</div>
</div>
);
}
function plainTextToHtml(value: string) {
return value
.replace(/&/g, "&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() {
const params = useParams();
const router = useRouter();
const invoiceId = params.id as string;
// State management
const [activeTab, setActiveTab] = useState("compose");
const [isSending, setIsSending] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [retryCount, setRetryCount] = useState(0);
// Email content state
const [subject, setSubject] = useState("");
const [emailContent, setEmailContent] = useState("");
const [ccEmail, setCcEmail] = useState("");
const [bccEmail, setBccEmail] = useState("");
const [customMessage, setCustomMessage] = useState("");
// Fetch invoice data
const { data: invoiceData, isLoading: invoiceLoading } =
api.invoices.getById.useQuery({
id: invoiceId,
});
// Get utils for cache invalidation
const utils = api.useUtils();
// Email sending mutation
const sendEmailMutation = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
toast.success("Email sent successfully!", {
description: data.message,
duration: 5000,
});
// Navigate back to invoice view
router.push(`/dashboard/invoices/${invoiceId}`);
// Refresh invoice data
void utils.invoices.getById.invalidate({ id: invoiceId });
},
onError: (error) => {
let errorMessage = "Failed to send invoice email";
let errorDescription = error.message;
let canRetry = false;
if (error.message.includes("Invalid recipient")) {
errorMessage = "Invalid Email Address";
errorDescription =
"Please check the client's email address and try again.";
} else if (error.message.includes("domain not verified")) {
errorMessage = "Email Configuration Issue";
errorDescription = "Please contact support to configure email sending.";
} else if (error.message.includes("rate limit")) {
errorMessage = "Too Many Emails";
errorDescription = "Please wait a moment before sending another email.";
canRetry = true;
} else if (error.message.includes("no email address")) {
errorMessage = "No Email Address";
errorDescription = "This client doesn't have an email address on file.";
} else if (
error.message.includes("unavailable") ||
error.message.includes("timeout")
) {
errorMessage = "Service Temporarily Unavailable";
errorDescription =
"The email service is temporarily unavailable. Please try again.";
canRetry = true;
} else {
canRetry = true; // Allow retry for unknown errors
}
toast.error(errorMessage, {
description:
canRetry && retryCount < 2
? `${errorDescription} You can retry this operation.`
: errorDescription,
duration: 6000,
action:
canRetry && retryCount < 2
? {
label: "Retry",
onClick: () => handleRetry(),
}
: undefined,
});
setIsSending(false);
},
});
// Transform invoice data for components
const invoice = useMemo(() => {
return invoiceData
? {
id: invoiceData.id,
invoiceNumber: invoiceData.invoiceNumber,
issueDate: invoiceData.issueDate,
dueDate: invoiceData.dueDate,
status: invoiceData.status,
totalAmount: invoiceData.totalAmount,
taxRate: invoiceData.taxRate,
currency: invoiceData.currency,
emailMessage: invoiceData.emailMessage,
client: invoiceData.client
? {
name: invoiceData.client.name,
email: invoiceData.client.email,
}
: undefined,
business: invoiceData.business
? {
name: invoiceData.business.name,
nickname: invoiceData.business.nickname,
email: invoiceData.business.email,
}
: undefined,
items: invoiceData.items?.map((item) => ({
id: item.id,
date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
})),
}
: undefined;
}, [invoiceData]);
const normalizedCustomMessage = useMemo(
() => normalizeEmailNoteHtml(customMessage),
[customMessage],
);
// Initialize email content when invoice loads
useEffect(() => {
if (!invoice || isInitialized) return;
// Set default subject
const defaultSubject = `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`;
// eslint-disable-next-line react-hooks/set-state-in-effect
setSubject(defaultSubject);
// Set default content (empty since template handles everything)
const defaultContent = ``;
setEmailContent(defaultContent);
setCustomMessage(
invoice.emailMessage ? plainTextToHtml(invoice.emailMessage) : "",
);
setIsInitialized(true);
}, [invoice, isInitialized]);
const handleSendEmail = async () => {
if (!invoice?.client?.email || invoice.client.email.trim() === "") {
toast.error("No email address", {
description: "This client doesn't have an email address on file.",
});
return;
}
if (!subject.trim()) {
toast.error("Subject required", {
description: "Please enter an email subject before sending.",
});
return;
}
// Show confirmation dialog
setShowConfirmDialog(true);
};
const confirmSendEmail = async () => {
setShowConfirmDialog(false);
setIsSending(true);
try {
await sendEmailMutation.mutateAsync({
invoiceId,
customSubject: subject,
customContent: emailContent,
customMessage: normalizedCustomMessage,
useHtml: true,
ccEmails: ccEmail.trim() || undefined,
bccEmails: bccEmail.trim() || undefined,
});
setRetryCount(0); // Reset retry count on success
} catch {
// Error handling is done in the mutation's onError
}
};
const handleRetry = () => {
if (retryCount < 2) {
setRetryCount((prev) => prev + 1);
void confirmSendEmail();
}
};
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com";
const toEmail = invoice?.client?.email ?? "";
const canSend =
!isSending && subject.trim() && toEmail && toEmail.trim() !== "";
if (invoiceLoading) {
return <SendEmailPageSkeleton />;
}
if (!invoice) {
return (
<div className="page-enter space-y-6">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>Invoice not found.</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="page-enter space-y-6 pb-32">
<PageHeader
title={`Send Invoice ${invoice.invoiceNumber}`}
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
},
).format(new Date())}`}
variant="gradient"
>
<Button
variant="outline"
onClick={() => router.push(`/dashboard/invoices/${invoiceId}`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Invoice
</Button>
</PageHeader>
{/* Warning for missing email */}
{(!toEmail || toEmail.trim() === "") && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This client doesn&apos;t have an email address. Please add an email
address to the client before sending the invoice.
</AlertDescription>
</Alert>
)}
{/* Main Content */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="compose" className="flex items-center gap-2">
<Edit3 className="h-4 w-4" />
Compose
</TabsTrigger>
<TabsTrigger value="preview" className="flex items-center gap-2">
<Eye className="h-4 w-4" />
Preview
</TabsTrigger>
</TabsList>
<div className="mt-6">
<TabsContent value="compose" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Compose Email
</CardTitle>
</CardHeader>
<CardContent>
{isInitialized ? (
<EmailComposer
subject={subject}
onSubjectChange={setSubject}
content={emailContent}
onContentChange={setEmailContent}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
onCcEmailChange={setCcEmail}
bccEmail={bccEmail}
onBccEmailChange={setBccEmail}
/>
) : (
<div className="bg-muted flex h-[400px] items-center justify-center border">
<div className="text-center">
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin border-2 border-t-transparent"></div>
<p className="text-muted-foreground text-sm">
Initializing email content...
</p>
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="preview" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Email Preview
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
bccEmail={bccEmail}
content={emailContent}
customMessage={normalizedCustomMessage}
invoice={invoice}
className="min-w-0 border-0"
/>
</div>
</CardContent>
</Card>
</TabsContent>
</div>
</Tabs>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Invoice Summary */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="text-primary h-5 w-5" />
Invoice #{invoice.invoiceNumber}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="text-muted-foreground text-sm font-medium">
Client
</Label>
<p className="text-sm font-medium">
{invoice.client?.name ?? "Client"}
</p>
</div>
<div>
<Label className="text-muted-foreground text-sm font-medium">
Issue Date
</Label>
<p className="text-sm">
{new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(invoice.issueDate))}
</p>
</div>
<div>
<Label className="text-muted-foreground text-sm font-medium">
Status
</Label>
<Badge variant="outline">{invoice.status}</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Email Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="text-muted-foreground text-sm font-medium">
From
</Label>
<p className="font-mono text-sm break-all">{fromEmail}</p>
</div>
<div>
<Label className="text-muted-foreground text-sm font-medium">
To
</Label>
<p className="font-mono text-sm break-all">
{toEmail || "No email address"}
</p>
</div>
{ccEmail && (
<div>
<Label className="text-muted-foreground text-sm font-medium">
CC
</Label>
<p className="font-mono text-sm break-all">{ccEmail}</p>
</div>
)}
{bccEmail && (
<div>
<Label className="text-muted-foreground text-sm font-medium">
BCC
</Label>
<p className="font-mono text-sm break-all">{bccEmail}</p>
</div>
)}
<div>
<Label className="text-muted-foreground text-sm font-medium">
Subject
</Label>
<p className="text-sm break-words">{subject || "No subject"}</p>
</div>
<Separator />
<div>
<Label className="text-muted-foreground text-sm font-medium">
Attachment
</Label>
<div className="flex items-center gap-2 text-sm">
<FileText className="h-3 w-3" />
<span>invoice-{invoice.invoiceNumber}.pdf</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{activeTab === "compose" && (
<Button
onClick={() => setActiveTab("preview")}
disabled={!subject.trim()}
className="w-full"
variant="outline"
>
<Eye className="mr-2 h-4 w-4" />
Preview Email
</Button>
)}
{activeTab === "preview" && (
<Button
onClick={() => setActiveTab("compose")}
variant="outline"
className="w-full"
>
<Edit3 className="mr-2 h-4 w-4" />
Edit Email
</Button>
)}
</CardContent>
</Card>
</div>
</div>
{/* Floating Action Bar */}
<FloatingActionBar
leftContent={
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<Send className="text-primary h-5 w-5" />
</div>
<div>
<p className="text-foreground font-medium">Send Invoice</p>
<p className="text-muted-foreground text-sm">
Email invoice to {invoice.client?.name ?? "client"}
</p>
</div>
</div>
}
>
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/dashboard/invoices/${invoiceId}`)}
>
Cancel
</Button>
<Button
onClick={handleSendEmail}
disabled={!canSend || isSending}
variant="default"
size="sm"
>
{isSending ? (
<>
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
<span className="hidden sm:inline">Sending...</span>
</>
) : (
<>
<Send className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Send Email</span>
</>
)}
</Button>
</FloatingActionBar>
{/* Confirmation Dialog */}
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm</DialogTitle>
<DialogDescription>
Send this invoice email to <strong>{toEmail}</strong>
{ccEmail && (
<>
{" "}
with CC to <strong>{ccEmail}</strong>
</>
)}
{bccEmail && (
<>
{" "}
and BCC to <strong>{bccEmail}</strong>
</>
)}
?
</DialogDescription>
{retryCount > 0 && (
<p className="text-muted-foreground text-sm">
Retry attempt {retryCount} of 2
</p>
)}
</DialogHeader>
<div className="bg-muted/30 space-y-2 border p-3 text-sm">
<div>
<span className="text-muted-foreground">Subject: </span>
<span className="font-medium">{subject}</span>
</div>
<div>
<span className="text-muted-foreground">Attachment: </span>
<span>invoice-{invoice.invoiceNumber}.pdf</span>
</div>
{normalizedCustomMessage && (
<div>
<span className="text-muted-foreground">Email note: </span>
<span>Included</span>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowConfirmDialog(false)}
>
Cancel
</Button>
<Button onClick={confirmSendEmail} variant="default">
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,15 +1,43 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { ColumnDef } from "@tanstack/react-table";
import type { ColumnDef, Row } from "@tanstack/react-table";
import { Checkbox } from "~/components/ui/checkbox";
import { Button } from "~/components/ui/button";
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
import { Eye, Edit } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import {
Eye,
Edit,
Trash2,
FileText,
CheckCircle,
Send,
ChevronDown,
} from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { formatCurrency } from "~/lib/currency";
import type { StoredInvoiceStatus } from "~/types/invoice";
// Type for invoice data
interface Invoice {
id: string;
invoiceNumber: string;
@@ -20,6 +48,7 @@ interface Invoice {
status: string;
totalAmount: number;
taxRate: number;
currency: string;
notes: string | null;
createdById: string;
createdAt: Date;
@@ -53,40 +82,85 @@ interface InvoicesDataTableProps {
invoices: Invoice[];
}
const getStatusType = (invoice: Invoice): StatusType => {
if (invoice.status === "paid") return "paid";
if (invoice.status === "draft") return "draft";
if (invoice.status === "overdue") return "overdue";
if (invoice.status === "sent") {
const dueDate = new Date(invoice.dueDate);
return dueDate < new Date() ? "overdue" : "sent";
}
return "draft";
};
const getStatusType = (invoice: Invoice): StatusType =>
getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) as StatusType;
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
}).format(new Date(date));
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [pendingBulkDelete, setPendingBulkDelete] = useState<Invoice[]>([]);
const handleRowClick = (invoice: Invoice) => {
router.push(`/dashboard/invoices/${invoice.id}`);
};
const utils = api.useUtils();
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
toast.success("Invoice deleted");
void utils.invoices.getAll.invalidate();
setDeleteDialogOpen(false);
setInvoiceToDelete(null);
},
onError: (e) => toast.error(e.message ?? "Failed to delete invoice"),
});
const bulkDelete = api.invoices.bulkDelete.useMutation({
onSuccess: (data) => {
toast.success(
`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`,
);
void utils.invoices.getAll.invalidate();
setBulkDeleteDialogOpen(false);
setPendingBulkDelete([]);
},
onError: (e) => toast.error(e.message ?? "Failed to delete invoices"),
});
const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({
onSuccess: (data) => {
toast.success(
`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`,
);
void utils.invoices.getAll.invalidate();
},
onError: (e) => toast.error(e.message ?? "Failed to update invoices"),
});
const columns: ColumnDef<Invoice>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="Select all"
data-action-button="true"
/>
),
cell: ({ row }: { row: Row<Invoice> }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="Select row"
data-action-button="true"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "client.name",
header: ({ column }) => (
@@ -95,13 +169,27 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
cell: ({ row }) => {
const invoice = row.original;
return (
<div className="max-w-[80px] min-w-0 sm:max-w-[200px] lg:max-w-[300px]">
<p className="truncate font-medium">
{invoice.client?.name ?? "—"}
</p>
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{invoice.invoiceNumber}
</p>
<div className="flex items-center gap-3">
<div className="bg-primary/10 hidden p-2 sm:flex">
<FileText className="text-primary h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{invoice.client?.name ?? "—"}
</p>
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{invoice.invoiceNumber}
</p>
<div className="mt-1 flex items-center gap-2 sm:hidden">
<StatusBadge
status={getStatusType(invoice)}
className="text-xs"
/>
<span className="text-foreground text-xs font-semibold">
{formatCurrency(invoice.totalAmount, invoice.currency)}
</span>
</div>
</div>
</div>
);
},
@@ -111,32 +199,32 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Date" />
),
cell: ({ row }) => {
const date = row.getValue("issueDate");
return (
<div className="min-w-0">
<p className="truncate text-sm">{formatDate(date as Date)}</p>
<p className="text-muted-foreground truncate text-xs">
Due {formatDate(new Date(row.original.dueDate))}
</p>
</div>
);
},
cell: ({ row }) => (
<div className="min-w-0">
<p className="truncate text-sm">
{formatDate(row.getValue("issueDate"))}
</p>
<p className="text-muted-foreground truncate text-xs">
Due {formatDate(new Date(row.original.dueDate))}
</p>
</div>
),
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const invoice = row.original;
return <StatusBadge status={getStatusType(invoice)} />;
},
filterFn: (row, id, value: string[]) => {
const invoice = row.original;
const status = getStatusType(invoice);
return value.includes(status);
},
cell: ({ row }) => (
<StatusBadge
status={getStatusType(row.original)}
className={
getStatusType(row.original) === "sent" ? "status-pending" : ""
}
/>
),
filterFn: (row, _id, value: string[]) =>
value.includes(getStatusType(row.original)),
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
@@ -147,19 +235,16 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Amount" />
),
cell: ({ row }) => {
const amount = row.getValue("totalAmount");
return (
<div className="text-right">
<p className="text-sm font-semibold">
{formatCurrency(amount as number)}
</p>
<p className="text-muted-foreground text-xs">
{row.original.items?.length ?? 0} items
</p>
</div>
);
},
cell: ({ row }) => (
<div className="text-right">
<p className="text-sm font-semibold">
{formatCurrency(row.getValue("totalAmount"), row.original.currency)}
</p>
<p className="text-muted-foreground text-xs">
{row.original.items?.length ?? 0} items
</p>
</div>
),
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
@@ -175,7 +260,7 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
>
<Eye className="h-3.5 w-3.5" />
@@ -185,12 +270,25 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
>
<Edit className="h-3.5 w-3.5" />
</Button>
</Link>
<Button
variant="ghost"
size="sm"
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
setInvoiceToDelete(invoice);
setDeleteDialogOpen(true);
}}
data-action-button="true"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
{invoice.items && invoice.client && (
<div data-action-button="true">
<PDFDownloadButton invoiceId={invoice.id} variant="icon" />
@@ -216,13 +314,153 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
];
return (
<DataTable
columns={columns}
data={invoices}
searchKey="invoiceNumber"
searchPlaceholder="Search invoices..."
filterableColumns={filterableColumns}
onRowClick={handleRowClick}
/>
<>
<DataTable
columns={columns}
data={invoices}
searchKey="invoiceNumber"
searchPlaceholder="Search invoices..."
filterableColumns={filterableColumns}
onRowClick={(invoice) =>
router.push(`/dashboard/invoices/${invoice.id}`)
}
selectionActions={(selected, clear) => (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={bulkUpdateStatus.isPending}
>
<Send className="mr-1.5 h-3.5 w-3.5" />
Mark as
<ChevronDown className="ml-1.5 h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
bulkUpdateStatus.mutate(
{ ids: selected.map((i) => i.id), status: "sent" },
{ onSuccess: clear },
)
}
>
<Send className="mr-2 h-4 w-4" /> Mark Sent
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
bulkUpdateStatus.mutate(
{ ids: selected.map((i) => i.id), status: "paid" },
{ onSuccess: clear },
)
}
>
<CheckCircle className="mr-2 h-4 w-4" /> Mark Paid
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
bulkUpdateStatus.mutate(
{ ids: selected.map((i) => i.id), status: "draft" },
{ onSuccess: clear },
)
}
>
<FileText className="mr-2 h-4 w-4" /> Mark Draft
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="destructive"
size="sm"
disabled={bulkDelete.isPending}
onClick={() => {
setPendingBulkDelete(selected);
setBulkDeleteDialogOpen(true);
}}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete ({selected.length})
</Button>
</>
)}
/>
{/* Single delete dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Invoice</DialogTitle>
<DialogDescription>
Are you sure you want to delete invoice{" "}
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
<strong>{invoiceToDelete?.client?.name}</strong>? This action
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={deleteInvoice.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() =>
invoiceToDelete &&
deleteInvoice.mutate({ id: invoiceToDelete.id })
}
disabled={deleteInvoice.isPending}
>
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk delete dialog */}
<Dialog
open={bulkDeleteDialogOpen}
onOpenChange={setBulkDeleteDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Delete {pendingBulkDelete.length} Invoice
{pendingBulkDelete.length !== 1 ? "s" : ""}
</DialogTitle>
<DialogDescription>
This will permanently delete {pendingBulkDelete.length} invoice
{pendingBulkDelete.length !== 1 ? "s" : ""}. This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setBulkDeleteDialogOpen(false)}
disabled={bulkDelete.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() =>
bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })
}
disabled={bulkDelete.isPending}
>
{bulkDelete.isPending
? "Deleting..."
: `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+39 -40
View File
@@ -1,37 +1,36 @@
import { Suspense } from "react";
import Link from "next/link";
import { HydrateClient } from "~/trpc/server";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { PageHeader } from "~/components/layout/page-header";
import { CSVImportPage } from "~/components/csv-import-page";
import {
ArrowLeft,
Upload,
FileText,
Download,
CheckCircle,
AlertCircle,
Info,
ArrowLeft,
CheckCircle,
Download,
FileSpreadsheet,
FileText,
Info,
Upload,
} from "lucide-react";
import Link from "next/link";
import { CSVImportPage } from "~/components/csv-import-page";
import { PageHeader } from "~/components/layout/page-header";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { HydrateClient } from "~/trpc/server";
// File Upload Instructions Component
function FormatInstructions() {
return (
<div className="grid gap-6 lg:grid-cols-2">
{/* Required Format */}
<Card className="card-primary">
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="card-title-info">
<FileText className="text-icon-blue h-5 w-5" />
<CardTitle className="text-foreground flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
Required CSV Format
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted-subtle rounded-lg p-4">
<p className="text-secondary font-mono text-sm">
<div className="bg-muted/50 p-4">
<p className="text-muted-foreground font-mono text-sm">
DATE,DESCRIPTION,HOURS,RATE,AMOUNT
</p>
</div>
@@ -50,7 +49,7 @@ function FormatInstructions() {
},
].map((col) => (
<div key={col.field} className="flex items-start gap-3">
<Badge className="badge-outline text-xs">{col.field}</Badge>
<Badge className="border text-xs">{col.field}</Badge>
<span className="text-muted-foreground text-sm">
{col.desc}
</span>
@@ -73,10 +72,10 @@ function FormatInstructions() {
</Card>
{/* Sample Data & Download */}
<Card className="card-primary">
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="card-title-secondary">
<Download className="text-icon-green h-5 w-5" />
<CardTitle className="text-foreground flex items-center gap-2">
<Download className="text-primary h-5 w-5" />
Sample Template
</CardTitle>
</CardHeader>
@@ -86,9 +85,9 @@ function FormatInstructions() {
for importing time entries.
</p>
<div className="bg-green-subtle rounded-lg p-4">
<div className="bg-primary/10 p-4">
<div className="flex items-start gap-3">
<Info className="text-icon-green mt-0.5 h-5 w-5" />
<Info className="text-primary mt-0.5 h-5 w-5" />
<div>
<p className="text-success text-sm font-medium">Pro Tip</p>
<p className="text-success text-sm">
@@ -101,7 +100,7 @@ function FormatInstructions() {
<div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Row:</h4>
<div className="bg-muted-subtle rounded-lg p-3">
<div className="bg-muted/50 p-3">
<p className="text-muted font-mono text-xs break-all">
1/15/24,&quot;Web development work&quot;,8,75.00,600.00
</p>
@@ -110,7 +109,7 @@ function FormatInstructions() {
<div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Filename:</h4>
<div className="bg-muted-subtle rounded-lg p-3">
<div className="bg-muted/50 p-3">
<p className="text-muted font-mono text-xs">2024-01-15.csv</p>
</div>
</div>
@@ -123,10 +122,10 @@ function FormatInstructions() {
// Important Notes Section
function ImportantNotes() {
return (
<Card className="card-primary border-l-4 border-l-amber-500">
<Card className="bg-card border-border border border-l-4 border-l-amber-500">
<CardHeader>
<CardTitle className="card-title-warning">
<AlertCircle className="text-icon-amber h-5 w-5" />
<CardTitle className="text-destructive flex items-center gap-2">
<AlertCircle className="text-primary h-5 w-5" />
Important Notes
</CardTitle>
</CardHeader>
@@ -159,18 +158,18 @@ function ImportantNotes() {
// File Format Help Section
function FileFormatHelp() {
return (
<Card className="card-primary">
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="card-title-info">
<FileSpreadsheet className="text-icon-blue h-5 w-5" />
<CardTitle className="text-foreground flex items-center gap-2">
<FileSpreadsheet className="text-primary h-5 w-5" />
Supported File Formats
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-2 text-center">
<div className="mx-auto w-fit rounded-full bg-blue-50 p-3 dark:bg-blue-900/20">
<FileSpreadsheet className="h-6 w-6 text-blue-600" />
<div className="bg-accent mx-auto w-fit p-3">
<FileSpreadsheet className="text-foreground-foreground h-6 w-6" />
</div>
<h4 className="font-semibold">CSV Files</h4>
<p className="text-muted-foreground text-sm">
@@ -179,8 +178,8 @@ function FileFormatHelp() {
</p>
</div>
<div className="space-y-2 text-center">
<div className="mx-auto w-fit rounded-full bg-green-50 p-3 dark:bg-green-900/20">
<Upload className="h-6 w-6 text-green-600" />
<div className="bg-primary/10 mx-auto w-fit p-3">
<Upload className="text-primary h-6 w-6" />
</div>
<h4 className="font-semibold">Max Size</h4>
<p className="text-muted-foreground text-sm">
@@ -188,8 +187,8 @@ function FileFormatHelp() {
</p>
</div>
<div className="space-y-2 text-center">
<div className="mx-auto w-fit rounded-full bg-purple-50 p-3 dark:bg-purple-900/20">
<CheckCircle className="h-6 w-6 text-purple-600" />
<div className="bg-secondary mx-auto w-fit p-3">
<CheckCircle className="text-muted-foreground-foreground h-6 w-6" />
</div>
<h4 className="font-semibold">Validation</h4>
<p className="text-muted-foreground text-sm">
+2 -714
View File
@@ -1,719 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { PageHeader } from "~/components/layout/page-header";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { DatePicker } from "~/components/ui/date-picker";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { toast } from "sonner";
import {
ArrowLeft,
Save,
Plus,
Trash2,
FileText,
Building,
User,
Loader2,
Send,
DollarSign,
Hash,
Edit3,
} from "lucide-react";
interface InvoiceItem {
tempId: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}
interface InvoiceFormData {
invoiceNumber: string;
businessId: string | undefined;
clientId: string;
issueDate: Date;
dueDate: Date;
notes: string;
taxRate: number;
items: InvoiceItem[];
}
function InvoiceItemCard({
item,
index,
onUpdate,
onDelete,
_isLast,
}: {
item: InvoiceItem;
index: number;
onUpdate: (
index: number,
field: keyof InvoiceItem,
value: string | number | Date,
) => void;
onDelete: (index: number) => void;
_isLast: boolean;
}) {
const handleFieldChange = (
field: keyof InvoiceItem,
value: string | number | Date,
) => {
onUpdate(index, field, value);
};
return (
<Card className="card-secondary">
<div className="space-y-3">
{/* Header with item number and delete */}
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium">
Item {index + 1}
</span>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-icon-red hover:text-error h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Item</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this line item? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(index)}
className="btn-danger"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{/* Description */}
<Textarea
value={item.description}
onChange={(e) => handleFieldChange("description", e.target.value)}
placeholder="Description of work..."
className="min-h-[48px] resize-none text-sm"
rows={1}
/>
{/* Date, Hours, Rate, Amount in compact grid */}
<div className="grid grid-cols-2 gap-2 text-sm md:grid-cols-4">
<div className="space-y-1">
<Label className="text-xs font-medium">Date</Label>
<DatePicker
date={item.date}
onDateChange={(date) =>
handleFieldChange("date", date ?? new Date())
}
size="sm"
className="w-full"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium">Hours</Label>
<NumberInput
value={item.hours}
onChange={(value) => handleFieldChange("hours", value)}
min={0}
step={0.25}
placeholder="0"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium">Rate</Label>
<NumberInput
value={item.rate}
onChange={(value) => handleFieldChange("rate", value)}
min={0}
step={0.25}
placeholder="0.00"
prefix="$"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium">Amount</Label>
<div className="bg-muted/30 flex h-8 items-center rounded-md border px-2">
<span className="amount-primary">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
</div>
</div>
</div>
</Card>
);
}
import InvoiceForm from "~/components/forms/invoice-form";
export default function NewInvoicePage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
// Initialize form data with defaults
const today = new Date();
const thirtyDaysFromNow = new Date(today);
thirtyDaysFromNow.setDate(today.getDate() + 30);
// Auto-generate invoice number
const generateInvoiceNumber = () => {
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const timestamp = Date.now().toString().slice(-4);
return `INV-${year}${month}-${timestamp}`;
};
const [formData, setFormData] = useState<InvoiceFormData>({
invoiceNumber: generateInvoiceNumber(),
businessId: undefined,
clientId: "",
issueDate: today,
dueDate: thirtyDaysFromNow,
notes: "",
taxRate: 0,
items: [
{
tempId: `item-${Date.now()}`,
date: today,
description: "",
hours: 0,
rate: 0,
amount: 0,
},
],
});
// Queries
const { data: clients, isLoading: clientsLoading } =
api.clients.getAll.useQuery();
const { data: businesses, isLoading: businessesLoading } =
api.businesses.getAll.useQuery();
// Set default business when data loads
useEffect(() => {
if (businesses && !formData.businessId) {
const defaultBusiness = businesses.find((b) => b.isDefault);
if (defaultBusiness) {
setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id }));
}
}
}, [businesses, formData.businessId]);
// Mutations
const createInvoice = api.invoices.create.useMutation({
onSuccess: (invoice) => {
toast.success("Invoice created successfully");
router.push(`/dashboard/invoices/${invoice.id}`);
},
onError: (error) => {
toast.error(error.message || "Failed to create invoice");
},
});
const handleItemUpdate = (
index: number,
field: keyof InvoiceItem,
value: string | number | Date,
) => {
const updatedItems = [...formData.items];
const currentItem = updatedItems[index];
if (currentItem) {
updatedItems[index] = { ...currentItem, [field]: value };
// Recalculate amount for hours or rate changes
if (field === "hours" || field === "rate") {
const updatedItem = updatedItems[index];
if (!updatedItem) return;
updatedItem.amount = updatedItem.hours * updatedItem.rate;
}
}
setFormData({ ...formData, items: updatedItems });
};
const handleItemDelete = (index: number) => {
if (formData.items.length === 1) {
toast.error("At least one line item is required");
return;
}
const updatedItems = formData.items.filter((_, i) => i !== index);
setFormData({ ...formData, items: updatedItems });
};
const handleAddItem = () => {
const newItem: InvoiceItem = {
tempId: `item-${Date.now()}`,
date: new Date(),
description: "",
hours: 0,
rate: 0,
amount: 0,
};
setFormData({
...formData,
items: [...formData.items, newItem],
});
};
const handleSaveDraft = async () => {
await handleSave("draft");
};
const handleCreateInvoice = async () => {
await handleSave("sent");
};
const handleSave = async (status: "draft" | "sent") => {
// Validation
if (!formData.clientId) {
toast.error("Please select a client");
return;
}
if (formData.items.length === 0) {
toast.error("At least one line item is required");
return;
}
// Check if all items have required fields
const invalidItems = formData.items.some(
(item) => !item.description.trim() || item.hours <= 0 || item.rate <= 0,
);
if (invalidItems) {
toast.error("All line items must have description, hours, and rate");
return;
}
setIsLoading(true);
try {
await createInvoice.mutateAsync({
...formData,
businessId: formData.businessId ?? undefined,
status,
});
} finally {
setIsLoading(false);
}
};
const calculateSubtotal = () => {
return formData.items.reduce((sum, item) => sum + item.amount, 0);
};
const calculateTax = () => {
return (calculateSubtotal() * formData.taxRate) / 100;
};
const calculateTotal = () => {
return calculateSubtotal() + calculateTax();
};
const isFormValid = () => {
return (
formData.clientId &&
formData.items.length > 0 &&
formData.items.every(
(item) => item.description.trim() && item.hours > 0 && item.rate > 0,
)
);
};
if (clientsLoading || businessesLoading) {
return (
<div className="space-y-6">
<PageHeader
title="Create Invoice"
description="Loading form data..."
variant="gradient"
/>
<Card className="card-primary">
<CardContent className="flex items-center justify-center p-8">
<Loader2 className="text-icon-emerald h-8 w-8 animate-spin" />
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6 pb-32">
<PageHeader
title="Create Invoice"
description="Fill out the details below to create a new invoice"
variant="gradient"
>
<Link href="/dashboard/invoices">
<Button variant="outline" size="sm" className="w-full md:w-auto">
<ArrowLeft className="mr-2 h-4 w-4" />
<span className="hidden md:inline">Back to Invoices</span>
<span className="md:hidden">Back</span>
</Button>
</Link>
</PageHeader>
<div className="space-y-6">
{/* Invoice Header */}
<Card className="card-primary">
<CardHeader>
<CardTitle className="card-title-secondary">
<FileText className="text-icon-emerald h-5 w-5" />
Invoice Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label className="text-sm font-medium">Invoice Number</Label>
<div className="bg-muted/30 flex h-10 items-center rounded-md border px-3">
<Hash className="text-muted-foreground mr-2 h-4 w-4" />
<span className="font-mono text-sm font-medium">
{formData.invoiceNumber}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Issue Date *</Label>
<DatePicker
date={formData.issueDate}
onDateChange={(date) =>
setFormData({
...formData,
issueDate: date ?? new Date(),
})
}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Due Date *</Label>
<DatePicker
date={formData.dueDate}
onDateChange={(date) =>
setFormData({
...formData,
dueDate: date ?? new Date(),
})
}
/>
</div>
</div>
</CardContent>
</Card>
{/* Business & Client */}
<Card className="card-primary">
<CardHeader>
<CardTitle className="card-title-secondary">
<Building className="text-icon-emerald h-5 w-5" />
Business & Client
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm font-medium">From Business</Label>
<div className="relative">
<Building className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Select
value={formData.businessId ?? ""}
onValueChange={(value) =>
setFormData({
...formData,
businessId: value || undefined,
})
}
>
<SelectTrigger className="pl-9">
<SelectValue placeholder="Select business..." />
</SelectTrigger>
<SelectContent>
{businesses?.map((business) => (
<SelectItem key={business.id} value={business.id}>
<div className="flex items-center gap-2">
<span>{business.name}</span>
{business.isDefault && (
<Badge className="badge-secondary text-xs">
Default
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{(!businesses || businesses.length === 0) && (
<p className="text-icon-red text-sm">
No businesses found.{" "}
<Link
href="/dashboard/businesses/new"
className="link-secondary"
>
Create one first
</Link>
</p>
)}
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Client *</Label>
<div className="relative">
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Select
value={formData.clientId}
onValueChange={(value) =>
setFormData({ ...formData, clientId: value })
}
>
<SelectTrigger className="pl-9">
<SelectValue placeholder="Select client..." />
</SelectTrigger>
<SelectContent>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
<div>
<div className="font-medium">{client.name}</div>
<div className="text-muted-foreground text-sm">
{client.email}
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{(!clients || clients.length === 0) && (
<p className="text-sm text-red-600">
No clients found.{" "}
<Link
href="/dashboard/clients/new"
className="underline hover:text-red-700"
>
Create one first
</Link>
</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Line Items */}
<Card className="card-primary">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Edit3 className="h-5 w-5 text-emerald-600" />
Line Items ({formData.items.length})
</CardTitle>
<Button
onClick={handleAddItem}
type="button"
variant="outline"
size="sm"
className="shrink-0"
>
<Plus className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">Add Item</span>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{formData.items.map((item, index) => (
<InvoiceItemCard
key={item.tempId}
item={item}
index={index}
onUpdate={handleItemUpdate}
onDelete={handleItemDelete}
_isLast={index === formData.items.length - 1}
/>
))}
</CardContent>
</Card>
{/* Tax & Totals */}
<Card className="card-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5 text-emerald-600" />
Tax & Totals
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2 md:col-span-1">
<Label className="text-sm font-medium">Tax Rate (%)</Label>
<NumberInput
value={formData.taxRate}
onChange={(value) =>
setFormData({
...formData,
taxRate: value,
})
}
min={0}
max={100}
step={0.01}
placeholder="0.00"
suffix="%"
width="full"
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Notes</Label>
<Textarea
value={formData.notes}
onChange={(e) =>
setFormData({ ...formData, notes: e.target.value })
}
placeholder="Payment terms, additional notes..."
rows={4}
className="resize-none"
/>
</div>
</div>
<div className="space-y-4">
<div className="bg-muted/20 rounded-lg border p-4">
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal:</span>
<span className="font-mono font-medium">
${calculateSubtotal().toFixed(2)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Tax ({formData.taxRate}%):
</span>
<span className="font-mono font-medium">
${calculateTax().toFixed(2)}
</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-bold">
<span>Total:</span>
<span className="font-mono text-emerald-600">
${calculateTotal().toFixed(2)}
</span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<FloatingActionBar
leftContent={
<div className="flex items-center space-x-3">
<div className="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">
Creating a new invoice
</p>
<p className="text-sm text-gray-600 dark:text-gray-300">
Complete the form to create your invoice
</p>
</div>
</div>
}
>
<Link href="/dashboard/invoices">
<Button
variant="outline"
disabled={isLoading}
className="border-border/40 hover:bg-accent/50"
size="sm"
>
<ArrowLeft className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">Cancel</span>
</Button>
</Link>
<Button
onClick={handleSaveDraft}
disabled={isLoading || !isFormValid()}
variant="outline"
className="border-border/40 hover:bg-accent/50"
size="sm"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin md:mr-2" />
) : (
<Save className="h-4 w-4 md:mr-2" />
)}
<span className="hidden md:inline">Save Draft</span>
</Button>
<Button
onClick={handleCreateInvoice}
disabled={isLoading || !isFormValid()}
className="btn-brand-primary shadow-md"
size="sm"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin md:mr-2" />
) : (
<Send className="h-4 w-4 md:mr-2" />
)}
<span className="hidden md:inline">Create Invoice</span>
</Button>
</FloatingActionBar>
</div>
);
return <InvoiceForm />;
}
+4 -4
View File
@@ -16,19 +16,19 @@ async function InvoicesTable() {
export default async function InvoicesPage() {
return (
<>
<div className="page-enter space-y-6">
<PageHeader
title="Invoices"
description="Manage your invoices and track payments"
variant="gradient"
>
<Button asChild variant="outline" className="shadow-sm">
<Button asChild variant="outline" className="hover-lift shadow-sm">
<Link href="/dashboard/invoices/import">
<Upload className="mr-2 h-5 w-5" />
<span>Import CSV</span>
</Link>
</Button>
<Button asChild className="btn-brand-primary shadow-md">
<Button asChild variant="default" className="hover-lift shadow-md">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-5 w-5" />
<span>Create Invoice</span>
@@ -41,6 +41,6 @@ export default async function InvoicesPage() {
<InvoicesTable />
</Suspense>
</HydrateClient>
</>
</div>
);
}
@@ -0,0 +1,325 @@
"use client";
import { useState } from "react";
import { api, type RouterOutputs } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { Checkbox } from "~/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
interface TemplateForm {
name: string;
type: "notes" | "terms";
content: string;
isDefault: boolean;
}
const defaultForm: TemplateForm = {
name: "",
type: "notes",
content: "",
isDefault: false,
};
type InvoiceTemplate = RouterOutputs["invoiceTemplates"]["getAll"][number];
interface TemplateListProps {
items: InvoiceTemplate[];
type: "notes" | "terms";
isLoading: boolean;
onCreate: (type: "notes" | "terms") => void;
onEdit: (template: InvoiceTemplate) => void;
onDelete: (id: string) => void;
}
function TemplateList({
items,
type,
isLoading,
onCreate,
onEdit,
onDelete,
}: TemplateListProps) {
return (
<div className="space-y-3">
<div className="flex justify-end">
<Button size="sm" onClick={() => onCreate(type)}>
<Plus className="mr-1.5 h-3.5 w-3.5" /> New{" "}
{type === "notes" ? "Notes" : "Terms"} Template
</Button>
</div>
{isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm">
Loading...
</div>
) : items.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">
No {type} templates yet.
</div>
) : (
items.map((template) => (
<Card key={template.id}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">{template.name}</p>
{template.isDefault && (
<Badge variant="secondary" className="text-xs">
<Star className="mr-1 h-3 w-3" /> Default
</Badge>
)}
</div>
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap">
{template.content}
</p>
</div>
<div className="flex flex-shrink-0 gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onEdit(template)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive h-8 w-8 p-0"
onClick={() => onDelete(template.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
);
}
export default function TemplatesPage() {
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<TemplateForm>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [tab, setTab] = useState<"notes" | "terms">("notes");
const utils = api.useUtils();
const { data: templates = [], isLoading } =
api.invoiceTemplates.getAll.useQuery();
const create = api.invoiceTemplates.create.useMutation({
onSuccess: () => {
toast.success("Template created");
void utils.invoiceTemplates.getAll.invalidate();
setOpen(false);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message),
});
const update = api.invoiceTemplates.update.useMutation({
onSuccess: () => {
toast.success("Template updated");
void utils.invoiceTemplates.getAll.invalidate();
setOpen(false);
setEditId(null);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message),
});
const del = api.invoiceTemplates.delete.useMutation({
onSuccess: () => {
toast.success("Template deleted");
void utils.invoiceTemplates.getAll.invalidate();
setDeleteId(null);
},
onError: (e) => toast.error(e.message),
});
const handleOpen = (type: "notes" | "terms") => {
setEditId(null);
setForm({ ...defaultForm, type });
setOpen(true);
};
const handleEdit = (t: InvoiceTemplate) => {
setEditId(t.id);
setForm({
name: t.name,
type: t.type as "notes" | "terms",
content: t.content,
isDefault: t.isDefault,
});
setOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) {
toast.error("Name is required");
return;
}
if (!form.content.trim()) {
toast.error("Content is required");
return;
}
if (editId) update.mutate({ id: editId, ...form });
else create.mutate(form);
};
const notesTemplates = templates.filter((t) => t.type === "notes");
const termsTemplates = templates.filter((t) => t.type === "terms");
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader
title="Invoice Templates"
description="Reusable notes and payment terms for your invoices"
variant="gradient"
/>
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes">
<FileText className="mr-1.5 h-4 w-4" /> Notes (
{notesTemplates.length})
</TabsTrigger>
<TabsTrigger value="terms">
<FileText className="mr-1.5 h-4 w-4" /> Terms (
{termsTemplates.length})
</TabsTrigger>
</TabsList>
<TabsContent value="notes" className="mt-4">
<TemplateList
items={notesTemplates}
type="notes"
isLoading={isLoading}
onCreate={handleOpen}
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent>
<TabsContent value="terms" className="mt-4">
<TemplateList
items={termsTemplates}
type="terms"
isLoading={isLoading}
onCreate={handleOpen}
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent>
</Tabs>
{/* Create/Edit dialog */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{editId ? "Edit Template" : "New Template"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Name *</Label>
<Input
value={form.name}
onChange={(e) =>
setForm((p) => ({ ...p, name: e.target.value }))
}
placeholder="e.g. Standard Payment Terms"
/>
</div>
<div className="space-y-2">
<Label>Type</Label>
<Tabs
value={form.type}
onValueChange={(v) =>
setForm((p) => ({ ...p, type: v as "notes" | "terms" }))
}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes">Notes</TabsTrigger>
<TabsTrigger value="terms">Terms</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="space-y-2">
<Label>Content *</Label>
<Textarea
value={form.content}
onChange={(e) =>
setForm((p) => ({ ...p, content: e.target.value }))
}
placeholder="Template content…"
className="min-h-[120px]"
/>
</div>
<label className="flex cursor-pointer items-center gap-2">
<Checkbox
checked={form.isDefault}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, isDefault: !!v }))
}
/>
<span className="text-sm">Set as default for {form.type}</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={create.isPending || update.isPending}
>
{create.isPending || update.isPending
? "Saving…"
: editId
? "Update"
: "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete dialog */}
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Template</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && del.mutate({ id: deleteId })}
disabled={del.isPending}
>
{del.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+2 -23
View File
@@ -1,30 +1,9 @@
import { Navbar } from "~/components/layout/navbar";
import { Sidebar } from "~/components/layout/sidebar";
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
import { DashboardShell } from "~/components/layout/dashboard-shell";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="floating-orbs relative min-h-screen">
<Navbar />
<Sidebar />
{/* Mobile layout - no left margin */}
<main className="relative z-10 min-h-screen pt-20 md:hidden">
<div className="px-4 pt-4 pb-6 sm:px-6">
<DashboardBreadcrumbs />
{children}
</div>
</main>
{/* Desktop layout - with sidebar margin */}
<main className="relative z-10 hidden min-h-screen pt-20 md:ml-[276px] md:block">
<div className="px-6 pt-6 pb-6">
<DashboardBreadcrumbs />
{children}
</div>
</main>
</div>
);
return <DashboardShell>{children}</DashboardShell>;
}
+329 -249
View File
@@ -1,193 +1,211 @@
import { Suspense } from "react";
import { HydrateClient, api } from "~/trpc/server";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Skeleton } from "~/components/ui/skeleton";
import { auth } from "~/server/auth";
import Link from "next/link";
import {
Users,
FileText,
DollarSign,
TrendingUp,
Plus,
ArrowUpRight,
Calendar,
Clock,
Eye,
Edit,
Activity,
ArrowUpRight,
BarChart3,
Calendar,
Edit,
Eye,
FileText,
Plus,
Users,
} from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Skeleton } from "~/components/ui/skeleton";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { auth } from "~/lib/auth";
import { headers } from "next/headers";
import { HydrateClient, api } from "~/trpc/server";
import type { StoredInvoiceStatus } from "~/types/invoice";
import { RevenueChart } from "~/app/dashboard/_components/revenue-chart";
import { InvoiceStatusChart } from "~/app/dashboard/_components/invoice-status-chart";
import { MonthlyMetricsChart } from "~/app/dashboard/_components/monthly-metrics-chart";
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
import type { DashboardStats, RecentInvoice } from "./types";
// Modern gradient background component
function DashboardHero({ firstName }: { firstName: string }) {
return (
<Card className="relative mb-8 overflow-hidden p-8 border-0 shadow-sm transition-shadow hover:shadow-md">
<div className="absolute inset-0" />
<div className="relative z-10">
<h1 className="mb-2 text-3xl font-bold">Welcome back, {firstName}!</h1>
<p className="text-lg">
Ready to manage your invoicing business
</p>
</div>
<div className="absolute -top-8 -right-8 h-32 w-32 rounded-full bg-white/10" />
<div className="absolute -right-4 -bottom-4 h-24 w-24 rounded-full bg-white/5" />
</Card>
);
}
// Hero section with clean mono design
// Enhanced stats cards with better visual hierarchy
async function DashboardStats() {
const [clients, invoices] = await Promise.all([
api.clients.getAll(),
api.invoices.getAll(),
]);
const totalClients = clients.length;
const totalInvoices = invoices.length;
const totalRevenue = invoices
.filter((invoice) => invoice.status === "paid")
.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
const pendingAmount = invoices
.filter((invoice) => invoice.status === "sent")
.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
// Enhanced stats cards with better visuals
function DashboardStats({ stats }: { stats: DashboardStats }) { // TODO: Import RouterOutput type
const formatTrend = (value: number, isCount = false) => {
if (isCount) {
return value > 0 ? `+${value}` : value.toString();
}
return value > 0 ? `+${value.toFixed(1)}%` : `${value.toFixed(1)}%`;
};
const stats = [
const statCards = [
{
title: "Total Revenue",
value: `$${totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
change: "+12.5%",
icon: DollarSign,
color: "",
bgColor: "bg-green-50",
changeColor: "",
value: `$${stats.totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
numericValue: stats.totalRevenue,
isCurrency: true,
change: formatTrend(stats.revenueChange),
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const),
iconName: "DollarSign" as const,
description: "Total collected revenue",
},
{
title: "Pending Amount",
value: `$${pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
change: "+8.2%",
icon: Clock,
color: "",
bgColor: "bg-amber-50",
changeColor: "",
value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
numericValue: stats.pendingAmount,
isCurrency: true,
change: "0%", // TODO: Calculate pending change if needed
trend: "neutral" as const,
iconName: "Clock" as const,
description: "Invoices awaiting payment",
},
{
title: "Active Clients",
value: totalClients.toString(),
change: "+3",
icon: Users,
color: "",
bgColor: "bg-blue-50",
changeColor: "",
value: stats.totalClients.toString(),
numericValue: stats.totalClients,
isCurrency: false,
change: "0", // TODO: Calculate client change if needed
trend: "neutral" as const,
iconName: "Users" as const,
description: "Total registered clients",
},
{
title: "Total Invoices",
value: totalInvoices.toString(),
change: "+15",
icon: FileText,
color: "",
bgColor: "bg-purple-50",
changeColor: "",
title: "Overdue Invoices",
value: stats.overdueCount.toString(),
numericValue: stats.overdueCount,
isCurrency: false,
change: "0", // TODO: Calculate overdue change if needed
trend: "neutral" as const,
iconName: "TrendingDown" as const,
description: "Invoices past due date",
},
];
return (
<div className="mb-8 grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<Card
key={stat.title}
className="border-0 shadow-sm transition-shadow hover:shadow-md"
>
<CardContent className="p-3 sm:p-4 lg:p-6">
<div className="mb-2 flex items-center justify-between sm:mb-3 lg:mb-4">
<div className={`rounded-lg p-1.5 sm:p-2 ${stat.bgColor}`}>
<Icon className="h-3 w-3 text-gray-700 sm:h-4 sm:w-4 lg:h-5 lg:w-5 dark:text-gray-800" />
</div>
<span className="text-xs font-medium text-teal-600 dark:text-teal-400">
{stat.change}
</span>
</div>
<div>
<p className="mb-1 text-base font-bold text-gray-900 sm:text-xl lg:text-2xl dark:text-gray-100">
{stat.value}
</p>
<p className="text-xs text-gray-600 lg:text-sm dark:text-gray-300">
{stat.title}
</p>
</div>
</CardContent>
</Card>
);
})}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{statCards.map((stat, index) => (
<AnimatedStatsCard
key={stat.title}
title={stat.title}
value={stat.value}
numericValue={stat.numericValue}
isCurrency={stat.isCurrency}
iconName={stat.iconName}
change={stat.change}
trend={stat.trend}
description={stat.description}
delay={index * 100}
/>
))}
</div>
);
}
// Quick Actions with better visual design
// Charts section
async function ChartsSection({ stats }: { stats: DashboardStats }) {
// We still fetch all invoices for the status chart for now, or we could aggregate that too.
// For now, let's keep status chart as is (fetching all) but use aggregated for revenue.
// Actually, let's fetch invoices here for the status chart to keep it working.
const invoices = await api.invoices.getAll();
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Revenue Trend Chart */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Revenue Over Time
</CardTitle>
</CardHeader>
<CardContent>
<RevenueChart data={stats.revenueChartData} />
</CardContent>
</Card>
{/* Invoice Status Breakdown */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Invoice Status
</CardTitle>
</CardHeader>
<CardContent>
<InvoiceStatusChart invoices={invoices} />
</CardContent>
</Card>
{/* Monthly Metrics */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Monthly Metrics
</CardTitle>
</CardHeader>
<CardContent>
<MonthlyMetricsChart invoices={invoices} />
</CardContent>
</Card>
</div>
);
}
// Enhanced Quick Actions
function QuickActions() {
const actions = [
{
title: "Create Invoice",
description: "Start a new invoice",
description: "Start a new invoice for a client",
href: "/dashboard/invoices/new",
icon: FileText,
primary: true,
featured: true,
},
{
title: "Add Client",
description: "Add a new client",
description: "Register a new client",
href: "/dashboard/clients/new",
icon: Users,
primary: false,
featured: false,
},
{
title: "View Reports",
description: "Business analytics",
href: "/dashboard/reports",
title: "View All Invoices",
description: "Manage your invoice pipeline",
href: "/dashboard/invoices",
icon: BarChart3,
primary: false,
featured: false,
},
];
return (
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Plus className="h-5 w-5 text-teal-600 dark:text-teal-400" />
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
Quick Actions
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<CardContent className="space-y-3">
{actions.map((action) => {
const Icon = action.icon;
return (
<Button
<Link
key={action.title}
asChild
variant={action.primary ? "default" : "outline"}
className={`h-12 w-full justify-start px-3 ${
action.primary
? "bg-teal-600 text-white hover:bg-teal-700"
: "border-gray-200 hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
}`}
href={action.href}
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${action.featured
? "border-foreground/20 bg-muted/50 hover:bg-muted"
: "border-border bg-background hover:bg-muted/50"
}`}
>
<Link href={action.href}>
<div className="flex items-center gap-3">
<Icon
className={`h-4 w-4 ${action.primary ? "text-white" : "text-gray-600 dark:text-gray-300"}`}
/>
<span
className={`font-medium ${action.primary ? "text-white" : "text-gray-900 dark:text-gray-100"}`}
>
{action.title}
</span>
</div>
</Link>
</Button>
<Icon className="h-5 w-5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-semibold">{action.title}</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{action.description}
</p>
</div>
</Link>
);
})}
</CardContent>
@@ -195,30 +213,35 @@ function QuickActions() {
);
}
// Current work in progress
// Current work section with enhanced design
async function CurrentWork() {
const invoices = await api.invoices.getAll();
const draftInvoices = invoices.filter(
(invoice) => invoice.status === "draft",
(invoice) =>
getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) === "draft",
);
const currentInvoice = draftInvoices[0];
if (!currentInvoice) {
return (
<Card className="border-0 shadow-sm">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Activity className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Current Work
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-8 text-center">
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-300 dark:text-gray-600" />
<p className="mb-4 text-gray-600 dark:text-gray-300">
No draft invoices found
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">No active drafts</h3>
<p className="text-muted-foreground mb-4">
Create a new invoice to get started
</p>
<Button asChild className="bg-teal-600 hover:bg-teal-700">
<Button asChild variant="outline" className="border-foreground/20">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
Create Invoice
@@ -234,49 +257,48 @@ async function CurrentWork() {
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
return (
<Card className="border-0 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<Activity className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Current Work
</CardTitle>
<Badge variant="secondary">In Progress</Badge>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-lg font-semibold">
<div className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-lg font-semibold break-words">
#{currentInvoice.invoiceNumber}
</p>
<p className="text-gray-600 dark:text-gray-300">
{currentInvoice.client?.name}
</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-teal-600 dark:text-teal-400">
</h3>
<span className="text-primary text-2xl font-bold">
${currentInvoice.totalAmount.toFixed(2)}
</p>
<p className="text-sm text-gray-600 dark:text-gray-300">
{totalHours.toFixed(1)} hours
</p>
</span>
</div>
<div className="text-muted-foreground flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between">
<span className="break-words">{currentInvoice.client?.name}</span>
<span className="text-xs sm:text-sm">
{totalHours.toFixed(1)} hours logged
</span>
</div>
</div>
<div className="flex gap-2">
<Button asChild variant="outline" size="sm" className="flex-1">
<Button
asChild
variant="outline"
size="sm"
className="hover-lift flex-1"
>
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
<Eye className="mr-2 h-3 w-3" />
<Eye className="mr-2 h-4 w-4" />
View
</Link>
</Button>
<Button
asChild
size="sm"
className="flex-1 bg-teal-600 hover:bg-teal-700"
>
<Button asChild size="sm" className="hover-lift flex-1">
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
<Edit className="mr-2 h-3 w-3" />
<Edit className="mr-2 h-4 w-4" />
Continue
</Link>
</Button>
@@ -287,51 +309,62 @@ async function CurrentWork() {
);
}
// Recent activity with enhanced design
async function RecentActivity() {
const invoices = await api.invoices.getAll();
const recentInvoices = invoices
.sort(
(a, b) =>
new Date(b.issueDate).getTime() - new Date(a.issueDate).getTime(),
)
.slice(0, 5);
// Enhanced recent activity
async function RecentActivity({ recentInvoices }: { recentInvoices: RecentInvoice[] }) {
// Use passed recentInvoices instead of fetching all
const getStatusColor = (status: string) => {
const getStatusStyle = (status: string) => {
switch (status) {
case "paid":
return "bg-green-50 border-green-200";
return {
backgroundColor: "oklch(var(--chart-2) / 0.1)",
borderColor: "oklch(var(--chart-2) / 0.3)",
color: "oklch(var(--chart-2))",
};
case "sent":
return "bg-blue-50 border-blue-200";
return {
backgroundColor: "oklch(var(--chart-1) / 0.1)",
borderColor: "oklch(var(--chart-1) / 0.3)",
color: "oklch(var(--chart-1))",
};
case "overdue":
return "bg-red-50 border-red-200";
return {
backgroundColor: "oklch(var(--chart-3) / 0.1)",
borderColor: "oklch(var(--chart-3) / 0.3)",
color: "oklch(var(--chart-3))",
};
default:
return "bg-gray-50 border-gray-200";
return {
backgroundColor: "hsl(var(--muted))",
borderColor: "hsl(var(--border))",
color: "hsl(var(--muted-foreground))",
};
}
};
return (
<Card className="border-0 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<Calendar className="h-5 w-5 text-purple-600 dark:text-purple-400" />
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Recent Activity
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href="/dashboard/invoices">
View All
<ArrowUpRight className="ml-1 h-4 w-4" />
<span className="hidden sm:inline">View All</span>
<ArrowUpRight className="h-4 w-4 sm:ml-1" />
</Link>
</Button>
</CardHeader>
<CardContent>
{recentInvoices.length === 0 ? (
<div className="py-8 text-center">
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-300 dark:text-gray-600" />
<p className="mb-4 text-gray-600 dark:text-gray-300">
No invoices yet
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">No invoices yet</h3>
<p className="text-muted-foreground mb-4">
Create your first invoice to get started
</p>
<Button asChild className="bg-teal-600 hover:bg-teal-700">
<Button asChild variant="outline" className="border-foreground/20">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
Create Your First Invoice
@@ -340,45 +373,42 @@ async function RecentActivity() {
</div>
) : (
<div className="space-y-3">
{recentInvoices.map((invoice) => (
{recentInvoices.map((invoice, _index) => (
<Link
key={invoice.id}
href={`/dashboard/invoices/${invoice.id}`}
className="block"
>
<Card className="card-secondary transition-colors hover:bg-gray-200/70 dark:hover:bg-gray-700/60">
<CardContent className="p-4">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-gray-100 p-2 dark:bg-gray-700">
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-300" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium text-gray-900 dark:text-gray-100">
<div className="recent-activity-item bg-muted/50 hover:bg-muted border-foreground/20 rounded-lg border p-3 transition-colors">
<div className="flex items-start gap-3">
<div className="bg-muted flex-shrink-0 rounded-lg p-2">
<FileText className="text-muted-foreground h-4 w-4" />
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="truncate font-medium">
#{invoice.invoiceNumber}
</p>
<p className="text-sm text-gray-600 dark:text-gray-300">
{invoice.client?.name} {" "}
{new Date(invoice.issueDate).toLocaleDateString()}
<p className="text-muted-foreground truncate text-sm">
{invoice.client?.name}
</p>
</div>
<div className="rounded-lg p-1 transition-colors hover:bg-gray-300/50 dark:hover:bg-gray-600/50">
<Eye className="h-4 w-4 text-gray-600 dark:text-gray-300" />
<div className="flex flex-shrink-0 items-center gap-2">
<Badge style={getStatusStyle(invoice.status)}>
{invoice.status}
</Badge>
<span className="text-primary font-semibold">
${invoice.totalAmount.toFixed(2)}
</span>
</div>
</div>
<div className="flex items-center justify-between">
<Badge
className={`border ${getStatusColor(invoice.status)}`}
>
{invoice.status}
</Badge>
<p className="font-semibold text-gray-900 dark:text-gray-100">
${invoice.totalAmount.toFixed(2)}
</p>
</div>
<p className="text-muted-foreground text-xs">
{new Date(invoice.issueDate).toLocaleDateString()}
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</Link>
))}
</div>
@@ -391,16 +421,16 @@ async function RecentActivity() {
// Loading skeletons
function StatsSkeleton() {
return (
<div className="mb-8 grid grid-cols-2 gap-3 sm:gap-6 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="border-0 shadow-sm">
<CardContent className="p-3 sm:p-4 lg:p-6">
<div className="mb-2 flex items-center justify-between sm:mb-3 lg:mb-4">
<Skeleton className="h-6 w-6 rounded-lg sm:h-8 sm:w-8 lg:h-9 lg:w-9" />
<Skeleton className="h-3 w-8 sm:h-4 sm:w-12" />
<Card key={i}>
<CardContent className="p-6">
<div className="flex items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-12" />
</div>
<Skeleton className="mb-1 h-5 w-16 sm:mb-2 sm:h-6 sm:w-20 lg:h-8" />
<Skeleton className="h-3 w-20 sm:h-4 sm:w-24" />
<Skeleton className="mb-2 h-8 w-20" />
<Skeleton className="h-3 w-32" />
</CardContent>
</Card>
))}
@@ -408,9 +438,40 @@ function StatsSkeleton() {
);
}
function ChartsSkeleton() {
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card className="lg:col-span-2">
<CardHeader>
<Skeleton className="h-6 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-36" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
</div>
);
}
function CardSkeleton() {
return (
<Card className="border-0 shadow-sm">
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
@@ -425,35 +486,54 @@ function CardSkeleton() {
);
}
import { DashboardPageHeader } from "~/components/layout/page-header";
// ... imports
export default async function DashboardPage() {
const session = await auth();
const session = await auth.api.getSession({
headers: await headers(),
});
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
// Fetch stats centrally
const stats = await api.dashboard.getStats();
return (
<div className="space-y-8">
<DashboardHero firstName={firstName} />
<div className="page-enter space-y-6">
<DashboardPageHeader
title={`Welcome back, ${firstName}!`}
description="Here's what's happening with your business today"
/>
<HydrateClient>
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats />
<DashboardStats stats={stats} />
</Suspense>
</HydrateClient>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<HydrateClient>
<Suspense fallback={<CardSkeleton />}>
<CurrentWork />
</Suspense>
</HydrateClient>
<QuickActions />
</div>
<HydrateClient>
<Suspense fallback={<CardSkeleton />}>
<RecentActivity />
<Suspense fallback={<ChartsSkeleton />}>
<ChartsSection stats={stats} />
</Suspense>
</HydrateClient>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="space-y-6">
<HydrateClient>
<Suspense fallback={<CardSkeleton />}>
<CurrentWork />
</Suspense>
</HydrateClient>
<QuickActions />
</div>
<HydrateClient>
<Suspense fallback={<CardSkeleton />}>
<RecentActivity recentInvoices={stats.recentInvoices} />
</Suspense>
</HydrateClient>
</div>
</div>
);
}
+847
View File
@@ -0,0 +1,847 @@
"use client";
import { useMemo, useState } from "react";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { StatusBadge } from "~/components/data/status-badge";
import { Button } from "~/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Separator } from "~/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { formatCurrency } from "~/lib/currency";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
import {
AreaChart,
Area,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import {
TrendingUp,
DollarSign,
Clock,
Users,
Download,
Receipt,
FileText,
} from "lucide-react";
function toNumericChartValue(value: unknown) {
const numericValue = typeof value === "number" ? value : Number(value ?? 0);
return Number.isFinite(numericValue) ? numericValue : 0;
}
export default function ReportsPage() {
const { data: invoices = [], isLoading: invoicesLoading } =
api.invoices.getAll.useQuery();
const { data: expenses = [], isLoading: expensesLoading } =
api.expenses.getAll.useQuery();
const { data: stats } = api.dashboard.getStats.useQuery();
const isLoading = invoicesLoading || expensesLoading;
const currentYear = new Date().getFullYear();
const [taxYear, setTaxYear] = useState(String(currentYear));
// Overview data (last 12 months)
const overviewData = useMemo(() => {
if (!invoices.length) return null;
const now = new Date();
const monthMap: Record<string, number> = {};
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
monthMap[key] = 0;
}
let totalRevenue = 0;
let totalPending = 0;
let totalHours = 0;
for (const inv of invoices) {
const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
);
if (status === "paid") {
totalRevenue += inv.totalAmount;
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`;
if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount;
} else if (status === "sent" || status === "overdue") {
totalPending += inv.totalAmount;
}
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
}
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
month: new Date(month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
revenue,
}));
const clientMap: Record<string, { name: string; revenue: number }> = {};
for (const inv of invoices) {
const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
);
if (status === "paid" && inv.client) {
const id = inv.client.id;
const entry = (clientMap[id] ??= {
name: inv.client.name,
revenue: 0,
});
entry.revenue += inv.totalAmount;
}
}
const topClients = Object.values(clientMap)
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 6);
const statusCount: Record<string, number> = {
draft: 0,
sent: 0,
paid: 0,
overdue: 0,
};
for (const inv of invoices) {
const s = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
);
statusCount[s] = (statusCount[s] ?? 0) + 1;
}
return {
revenueByMonth,
topClients,
totalRevenue,
totalPending,
totalHours,
statusCount,
};
}, [invoices]);
// Tax summary for selected year
const taxData = useMemo(() => {
const year = parseInt(taxYear);
const yearInvoices = invoices.filter((inv) => {
const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
);
return (
status === "paid" && new Date(inv.issueDate).getFullYear() === year
);
});
const yearExpenses = expenses.filter(
(exp) => new Date(exp.date).getFullYear() === year,
);
const getSubtotal = (inv: (typeof yearInvoices)[number]) => {
const itemSubtotal = (inv.items ?? []).reduce(
(s, item) => s + item.amount,
0,
);
if (itemSubtotal > 0) return itemSubtotal;
const taxMultiplier = 1 + (inv.taxRate ?? 0) / 100;
return taxMultiplier > 0
? inv.totalAmount / taxMultiplier
: inv.totalAmount;
};
const grossIncome = yearInvoices.reduce(
(s, inv) => s + getSubtotal(inv),
0,
);
const taxCollected = yearInvoices.reduce(
(s, inv) => s + (inv.totalAmount - getSubtotal(inv)),
0,
);
const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0);
const deductibleExpenses = yearExpenses
.filter(
(exp) =>
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible,
)
.reduce((s, exp) => s + exp.amount, 0);
const netProfit = grossIncome - deductibleExpenses;
const seTaxBase = Math.max(0, netProfit) * 0.9235;
const selfEmploymentTax = seTaxBase * 0.153;
const taxableIncome = Math.max(0, netProfit - selfEmploymentTax / 2);
const federalEstimate = taxableIncome * 0.22;
const totalEstimated = selfEmploymentTax + federalEstimate;
const quarters = [1, 2, 3, 4].map((q) => {
const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2];
return {
label: `Q${q}`,
income: yearInvoices
.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth()))
.reduce((s, inv) => s + getSubtotal(inv), 0),
expenses: yearExpenses
.filter((exp) => qMonths.includes(new Date(exp.date).getMonth()))
.reduce((s, exp) => s + exp.amount, 0),
};
});
return {
grossIncome,
taxCollected,
totalInvoiced: grossIncome + taxCollected,
totalExpenses,
deductibleExpenses,
netProfit,
selfEmploymentTax,
federalEstimate,
totalEstimated,
quarters,
yearInvoices,
yearExpenses,
};
}, [invoices, expenses, taxYear]);
const availableYears = useMemo(() => {
const years = new Set<number>([currentYear, currentYear - 1]);
for (const inv of invoices)
years.add(new Date(inv.issueDate).getFullYear());
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
return Array.from(years).sort((a, b) => b - a);
}, [invoices, expenses, currentYear]);
const avgInvoice =
invoices.length > 0
? (overviewData?.totalRevenue ?? 0) /
(invoices.filter(
(i) =>
getEffectiveInvoiceStatus(
i.status as StoredInvoiceStatus,
i.dueDate,
) === "paid",
).length || 1)
: 0;
function exportCSV() {
const rows: string[] = [
`Tax Year ${taxYear} - Income & Expense Report`,
`Generated: ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`,
"",
"INCOME (Paid Invoices)",
"Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total",
...taxData.yearInvoices.map((inv) => {
const subtotal = (inv.items ?? []).reduce(
(s, item) => s + item.amount,
0,
);
const fallbackSubtotal =
inv.totalAmount / (1 + (inv.taxRate ?? 0) / 100);
const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal;
const taxAmt = inv.totalAmount - invoiceSubtotal;
return [
new Date(inv.issueDate).toLocaleDateString("en-US"),
inv.invoiceNumber,
`"${inv.client?.name ?? ""}"`,
invoiceSubtotal.toFixed(2),
`${(inv.taxRate ?? 0).toFixed(1)}%`,
taxAmt.toFixed(2),
inv.totalAmount.toFixed(2),
].join(",");
}),
`,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`,
"",
"EXPENSES",
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
...taxData.yearExpenses.map((exp) =>
[
new Date(exp.date).toLocaleDateString("en-US"),
`"${exp.description}"`,
`"${exp.category ?? ""}"`,
exp.amount.toFixed(2),
exp.currency,
exp.billable ? "Yes" : "No",
exp.reimbursable ? "Yes" : "No",
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible
? "Yes"
: "No",
].join(","),
),
`,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`,
"",
"TAX SUMMARY",
`Gross Income,${taxData.grossIncome.toFixed(2)}`,
`Tax Collected,${taxData.taxCollected.toFixed(2)}`,
`Deductible Expenses,${taxData.deductibleExpenses.toFixed(2)}`,
`Net Profit,${taxData.netProfit.toFixed(2)}`,
`Est. Self-Employment Tax (15.3%),${taxData.selfEmploymentTax.toFixed(2)}`,
`Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`,
`Total Estimated Tax,${taxData.totalEstimated.toFixed(2)}`,
];
const blob = new Blob([rows.join("\n")], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `tax-report-${taxYear}.csv`;
a.click();
URL.revokeObjectURL(url);
}
if (isLoading) {
return (
<div className="page-enter space-y-6">
<PageHeader
title="Reports"
description="Revenue and tax analytics"
variant="gradient"
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
))}
</div>
</div>
);
}
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader
title="Reports"
description="Revenue and tax analytics"
variant="gradient"
/>
<Tabs defaultValue="overview">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="overview">
<TrendingUp className="mr-1.5 h-4 w-4" /> Overview
</TabsTrigger>
<TabsTrigger value="tax">
<FileText className="mr-1.5 h-4 w-4" /> Tax Summary
</TabsTrigger>
</TabsList>
{/* ── OVERVIEW TAB ── */}
<TabsContent value="overview" className="mt-4 space-y-6">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-primary/10 rounded p-1.5">
<DollarSign className="text-primary h-4 w-4" />
</div>
<p className="text-muted-foreground text-xs font-medium">
Total Revenue
</p>
</div>
<p className="mt-2 text-2xl font-bold">
{formatCurrency(overviewData?.totalRevenue ?? 0)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="rounded bg-yellow-500/10 p-1.5">
<Clock className="h-4 w-4 text-yellow-500" />
</div>
<p className="text-muted-foreground text-xs font-medium">
Pending
</p>
</div>
<p className="mt-2 text-2xl font-bold">
{formatCurrency(overviewData?.totalPending ?? 0)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="rounded bg-blue-500/10 p-1.5">
<TrendingUp className="h-4 w-4 text-blue-500" />
</div>
<p className="text-muted-foreground text-xs font-medium">
Avg Invoice
</p>
</div>
<p className="mt-2 text-2xl font-bold">
{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="rounded bg-green-500/10 p-1.5">
<Users className="h-4 w-4 text-green-500" />
</div>
<p className="text-muted-foreground text-xs font-medium">
Total Hours
</p>
</div>
<p className="mt-2 text-2xl font-bold">
{(overviewData?.totalHours ?? 0).toFixed(1)}h
</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 w-full md:h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={overviewData?.revenueByMonth ?? []}>
<defs>
<linearGradient
id="revenueGrad"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="hsl(142, 76%, 36%)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="hsl(142, 76%, 36%)"
stopOpacity={0.02}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-border"
/>
<XAxis
dataKey="month"
tick={{
fontSize: 11,
fill: "hsl(var(--muted-foreground))",
}}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{
fontSize: 11,
fill: "hsl(var(--muted-foreground))",
}}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) =>
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
}
/>
<Tooltip
formatter={(value) => [
formatCurrency(toNumericChartValue(value)),
"Revenue",
]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: 12,
}}
/>
<Area
type="monotone"
dataKey="revenue"
stroke="hsl(142, 76%, 36%)"
fill="url(#revenueGrad)"
strokeWidth={2}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" /> Top Clients by Revenue
</CardTitle>
</CardHeader>
<CardContent>
{!overviewData?.topClients.length ? (
<p className="text-muted-foreground py-6 text-center text-sm">
No paid invoices yet.
</p>
) : (
<div className="h-48 md:h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={overviewData.topClients}
layout="vertical"
>
<XAxis
type="number"
tick={{
fontSize: 11,
fill: "hsl(var(--muted-foreground))",
}}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) =>
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
}
/>
<YAxis
type="category"
dataKey="name"
tick={{
fontSize: 11,
fill: "hsl(var(--muted-foreground))",
}}
axisLine={false}
tickLine={false}
width={80}
/>
<Tooltip
formatter={(value) => [
formatCurrency(toNumericChartValue(value)),
"Revenue",
]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: 12,
}}
/>
<Bar
dataKey="revenue"
fill="hsl(142, 76%, 36%)"
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Invoice Status Breakdown</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Object.entries(overviewData?.statusCount ?? {}).map(
([status, count]) => (
<div
key={status}
className="flex items-center justify-between"
>
<StatusBadge status={status as never} />
<div className="flex items-center gap-3">
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
<div
className="bg-primary h-full rounded-full"
style={{
width: `${invoices.length ? (count / invoices.length) * 100 : 0}%`,
}}
/>
</div>
<span className="text-muted-foreground w-8 text-right text-sm">
{count}
</span>
</div>
</div>
),
)}
{invoices.length === 0 && (
<p className="text-muted-foreground py-6 text-center text-sm">
No invoices yet.
</p>
)}
</CardContent>
</Card>
</div>
{stats && (
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y">
{stats.recentInvoices.map((inv) => (
<div
key={inv.id}
className="flex items-center justify-between py-3"
>
<div>
<p className="font-medium">{inv.client?.name ?? "—"}</p>
<p className="text-muted-foreground text-xs">
{new Date(inv.issueDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</p>
</div>
<div className="flex items-center gap-3">
<StatusBadge
status={
getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
) as never
}
/>
<p className="font-semibold">
{formatCurrency(inv.totalAmount)}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* ── TAX SUMMARY TAB ── */}
<TabsContent value="tax" className="mt-4 space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Tax Year</span>
<Select value={taxYear} onValueChange={setTaxYear}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableYears.map((y) => (
<SelectItem key={y} value={String(y)}>
{y}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="outline" onClick={exportCSV} className="gap-2">
<Download className="h-4 w-4" /> Export CSV
</Button>
</div>
{/* Income */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5" /> Income
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Gross Income (paid invoices)
</span>
<span className="font-medium">
{formatCurrency(taxData.grossIncome)}
</span>
</div>
{taxData.taxCollected > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Tax Collected from Clients
</span>
<span className="font-medium">
{formatCurrency(taxData.taxCollected)}
</span>
</div>
)}
<Separator />
<div className="flex justify-between font-medium">
<span>Total Invoiced (inc. tax)</span>
<span>{formatCurrency(taxData.totalInvoiced)}</span>
</div>
</CardContent>
</Card>
{/* Expenses */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" /> Expenses & Deductions
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Expenses</span>
<span className="font-medium">
{formatCurrency(taxData.totalExpenses)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Tax-Deductible Expenses
</span>
<span className="font-medium text-green-600">
{formatCurrency(taxData.deductibleExpenses)}
</span>
</div>
{taxData.totalExpenses > 0 &&
taxData.deductibleExpenses === 0 && (
<p className="text-muted-foreground text-xs">
Mark expenses as &quot;Tax Deductible&quot; in the Expenses
page to include them here.
</p>
)}
</CardContent>
</Card>
{/* Estimated tax */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" /> Estimated Tax Liability
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Net Profit (income deductible expenses)
</span>
<span className="font-medium">
{formatCurrency(taxData.netProfit)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Self-Employment Tax (15.3% on 92.35% of net)
</span>
<span className="font-medium">
{formatCurrency(taxData.selfEmploymentTax)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Federal Income Tax (est. 22% bracket)
</span>
<span className="font-medium">
{formatCurrency(taxData.federalEstimate)}
</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-bold">
<span>Total Estimated Tax</span>
<span className="text-destructive">
{formatCurrency(taxData.totalEstimated)}
</span>
</div>
<p className="text-muted-foreground pt-1 text-xs">
Assumes US self-employment tax rules and the 22% federal
bracket. Consult a tax professional for accurate filing.
</p>
</CardContent>
</Card>
{/* Quarterly chart */}
<Card>
<CardHeader>
<CardTitle>Quarterly Breakdown</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 md:h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={taxData.quarters}>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-border"
/>
<XAxis
dataKey="label"
tick={{
fontSize: 11,
fill: "hsl(var(--muted-foreground))",
}}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{
fontSize: 11,
fill: "hsl(var(--muted-foreground))",
}}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) =>
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
}
/>
<Tooltip
formatter={(value, name) => [
formatCurrency(toNumericChartValue(value)),
name === "income" ? "Income" : "Expenses",
]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: 12,
}}
/>
<Bar
dataKey="income"
name="income"
fill="hsl(142, 76%, 36%)"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="expenses"
name="expenses"
fill="hsl(0, 84%, 60%)"
radius={[4, 4, 0, 0]}
opacity={0.75}
/>
</BarChart>
</ResponsiveContainer>
</div>
<div className="text-muted-foreground mt-2 flex justify-center gap-6 text-xs">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" />{" "}
Income
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" />{" "}
Expenses
</span>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More