Build fixes, email preview system

This commit is contained in:
2025-07-29 19:45:38 -04:00
parent e6791f8cb8
commit 9370d5c935
78 changed files with 5798 additions and 10397 deletions

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)
@@ -111,21 +118,21 @@ beenvoice is a professional invoicing application built with the T3 stack (Next.
interface ComponentProps {
// Required props
title: string;
// Optional props with defaults
variant?: "default" | "success" | "warning" | "error";
size?: "sm" | "md" | "lg";
// Styling props
className?: string;
// Event handlers
onClick?: () => void;
onChange?: (value: string) => void;
// Content
children?: React.ReactNode;
// Accessibility
"aria-label"?: string;
}
@@ -237,7 +244,7 @@ export const exampleRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
// Business logic here
}),
list: protectedProcedure
.input(z.object({ /* pagination/filtering */ }))
.query(async ({ ctx, input }) => {
@@ -425,10 +432,10 @@ export const exampleRouter = createTRPCRouter({
- Document emergency procedures
## Remember
This is a business application where reliability, security, and professional user experience are critical. Every decision should prioritize these values over development convenience or flashy features.
This is a business application where reliability, security, and professional user experience are critical. Every decision should prioritize these values over development convenience or flashy features.
- Don't create demo pages unless absolutely necessary.
- Don't create unnecessary complexity.
- Don't run builds unless absolutely necessary, if you do, kill the dev servers.
- Don't start new dev servers unless asked.
- Don't start drizzle studio- you cannot do anything with it.
- Don't start drizzle studio- you cannot do anything with it.

View File

@@ -15,9 +15,9 @@
# https://next-auth.js.org/configuration/options#secret
AUTH_SECRET=""
# Next Auth Discord Provider
AUTH_DISCORD_ID=""
AUTH_DISCORD_SECRET=""
# Drizzle
DATABASE_URL="file:./db.sqlite"
# Resend
RESEND_API_KEY=""
RESEND_DOMAIN=""

683
bun.lock

File diff suppressed because it is too large Load Diff

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
services:
db:
image: postgres:15
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: beenvoice
PGSSLMODE: disable
command: -c ssl=off
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:

281
docs/email-features.md Normal file
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

View File

@@ -4,14 +4,9 @@ import { env } from "~/env";
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,
},
dialect: "postgresql",
dbCredentials: {
url: env.DATABASE_URL,
},
tablesFilter: ["beenvoice_*"],
} satisfies Config;

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`);

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`)
);

View File

@@ -0,0 +1,130 @@
CREATE TABLE "beenvoice_account" (
"userId" varchar(255) NOT NULL,
"type" varchar(255) NOT NULL,
"provider" varchar(255) NOT NULL,
"providerAccountId" varchar(255) NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" integer,
"token_type" varchar(255),
"scope" varchar(255),
"id_token" text,
"session_state" varchar(255),
CONSTRAINT "beenvoice_account_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId")
);
--> statement-breakpoint
CREATE TABLE "beenvoice_business" (
"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),
"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 DEFAULT 100 NOT NULL,
"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" (
"sessionToken" varchar(255) PRIMARY KEY NOT NULL,
"userId" varchar(255) NOT NULL,
"expires" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "beenvoice_user" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255),
"email" varchar(255) NOT NULL,
"password" varchar(255),
"emailVerified" timestamp DEFAULT CURRENT_TIMESTAMP,
"image" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "beenvoice_verification_token" (
"identifier" varchar(255) NOT NULL,
"token" varchar(255) NOT NULL,
"expires" timestamp NOT NULL,
CONSTRAINT "beenvoice_verification_token_identifier_token_pk" PRIMARY KEY("identifier","token")
);
--> 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
CREATE INDEX "account_user_id_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_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");

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;

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`);

File diff suppressed because it is too large Load Diff

View File

@@ -1,683 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "5672a328-2801-45d8-9e27-bb9fe07e6c0e",
"prevId": "4d0fc78f-75b4-4059-b7f0-1aa656f007b7",
"tables": {
"beenvoice_account": {
"name": "beenvoice_account",
"columns": {
"userId": {
"name": "userId",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"account_user_id_idx": {
"name": "account_user_id_idx",
"columns": [
"userId"
],
"isUnique": false
}
},
"foreignKeys": {
"beenvoice_account_userId_beenvoice_user_id_fk": {
"name": "beenvoice_account_userId_beenvoice_user_id_fk",
"tableFrom": "beenvoice_account",
"tableTo": "beenvoice_user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"beenvoice_account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "beenvoice_account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"beenvoice_client": {
"name": "beenvoice_client",
"columns": {
"id": {
"name": "id",
"type": "text(255)",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"addressLine1": {
"name": "addressLine1",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"addressLine2": {
"name": "addressLine2",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"city": {
"name": "city",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"postalCode": {
"name": "postalCode",
"type": "text(20)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"country": {
"name": "country",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdById": {
"name": "createdById",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updatedAt": {
"name": "updatedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"client_created_by_idx": {
"name": "client_created_by_idx",
"columns": [
"createdById"
],
"isUnique": false
},
"client_name_idx": {
"name": "client_name_idx",
"columns": [
"name"
],
"isUnique": false
},
"client_email_idx": {
"name": "client_email_idx",
"columns": [
"email"
],
"isUnique": false
}
},
"foreignKeys": {
"beenvoice_client_createdById_beenvoice_user_id_fk": {
"name": "beenvoice_client_createdById_beenvoice_user_id_fk",
"tableFrom": "beenvoice_client",
"tableTo": "beenvoice_user",
"columnsFrom": [
"createdById"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"beenvoice_invoice_item": {
"name": "beenvoice_invoice_item",
"columns": {
"id": {
"name": "id",
"type": "text(255)",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"invoiceId": {
"name": "invoiceId",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text(500)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hours": {
"name": "hours",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rate": {
"name": "rate",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount": {
"name": "amount",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"invoice_item_invoice_id_idx": {
"name": "invoice_item_invoice_id_idx",
"columns": [
"invoiceId"
],
"isUnique": false
},
"invoice_item_date_idx": {
"name": "invoice_item_date_idx",
"columns": [
"date"
],
"isUnique": false
},
"invoice_item_position_idx": {
"name": "invoice_item_position_idx",
"columns": [
"position"
],
"isUnique": false
}
},
"foreignKeys": {
"beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk": {
"name": "beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk",
"tableFrom": "beenvoice_invoice_item",
"tableTo": "beenvoice_invoice",
"columnsFrom": [
"invoiceId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"beenvoice_invoice": {
"name": "beenvoice_invoice",
"columns": {
"id": {
"name": "id",
"type": "text(255)",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"invoiceNumber": {
"name": "invoiceNumber",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"clientId": {
"name": "clientId",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"issueDate": {
"name": "issueDate",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dueDate": {
"name": "dueDate",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"totalAmount": {
"name": "totalAmount",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"taxRate": {
"name": "taxRate",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"notes": {
"name": "notes",
"type": "text(1000)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdById": {
"name": "createdById",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updatedAt": {
"name": "updatedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"invoice_client_id_idx": {
"name": "invoice_client_id_idx",
"columns": [
"clientId"
],
"isUnique": false
},
"invoice_created_by_idx": {
"name": "invoice_created_by_idx",
"columns": [
"createdById"
],
"isUnique": false
},
"invoice_number_idx": {
"name": "invoice_number_idx",
"columns": [
"invoiceNumber"
],
"isUnique": false
},
"invoice_status_idx": {
"name": "invoice_status_idx",
"columns": [
"status"
],
"isUnique": false
}
},
"foreignKeys": {
"beenvoice_invoice_clientId_beenvoice_client_id_fk": {
"name": "beenvoice_invoice_clientId_beenvoice_client_id_fk",
"tableFrom": "beenvoice_invoice",
"tableTo": "beenvoice_client",
"columnsFrom": [
"clientId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"beenvoice_invoice_createdById_beenvoice_user_id_fk": {
"name": "beenvoice_invoice_createdById_beenvoice_user_id_fk",
"tableFrom": "beenvoice_invoice",
"tableTo": "beenvoice_user",
"columnsFrom": [
"createdById"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"beenvoice_session": {
"name": "beenvoice_session",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text(255)",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
"userId"
],
"isUnique": false
}
},
"foreignKeys": {
"beenvoice_session_userId_beenvoice_user_id_fk": {
"name": "beenvoice_session_userId_beenvoice_user_id_fk",
"tableFrom": "beenvoice_session",
"tableTo": "beenvoice_user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"beenvoice_user": {
"name": "beenvoice_user",
"columns": {
"id": {
"name": "id",
"type": "text(255)",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(unixepoch())"
},
"image": {
"name": "image",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"beenvoice_verification_token": {
"name": "beenvoice_verification_token",
"columns": {
"identifier": {
"name": "identifier",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"beenvoice_verification_token_identifier_token_pk": {
"columns": [
"identifier",
"token"
],
"name": "beenvoice_verification_token_identifier_token_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,12 +1,12 @@
{
"version": "7",
"dialect": "sqlite",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1752275489999,
"tag": "0000_unique_loa",
"version": "7",
"when": 1753825898609,
"tag": "0000_warm_squadron_sinister",
"breakpoints": true
}
]

View File

@@ -5,10 +5,6 @@
import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {
eslint: {
ignoreDuringBuilds: true,
},
};
const config = {};
export default config;

8182
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,8 @@
"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",
"docker-up": "colima start && docker-compose up -d",
"docker-down": "docker-compose down && colima stop",
"deploy": "drizzle-kit push && next build",
"dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
@@ -30,7 +32,6 @@
"@dnd-kit/core": "^6.3.1",
"@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",
@@ -49,31 +50,41 @@
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-color": "^3.0.7",
"@tiptap/extension-list-item": "^3.0.7",
"@tiptap/extension-text-align": "^3.0.7",
"@tiptap/extension-text-style": "^3.0.7",
"@tiptap/react": "^3.0.7",
"@tiptap/starter-kit": "^3.0.7",
"@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",
"@types/pg": "^8.15.5",
"bcryptjs": "^3.0.2",
"chrono-node": "^2.8.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.41.0",
"drizzle-orm": "^0.44.4",
"file-saver": "^2.0.5",
"lucide": "^0.525.0",
"lucide-react": "^0.525.0",
"next": "^15.4.2",
"next": "^15.4.4",
"next-auth": "5.0.0-beta.25",
"pg": "^8.16.3",
"react": "^19.0.0",
"react-day-picker": "^9.8.0",
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.8",
"resend": "^4.7.0",
"server-only": "^0.0.1",
"sonner": "^2.0.6",
"superjson": "^2.2.1",
"tailwind-merge": "^3.3.1",
"turso": "^0.1.0",
"vercel": "^44.6.4",
"zod": "^3.24.2"
},
"devDependencies": {

View File

@@ -49,29 +49,35 @@ function RegisterForm() {
}
return (
<div className="auth-container">
<div className="auth-form-container">
<div className="floating-orbs 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="auth-header">
<div className="space-y-4 text-center">
<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>
<h1 className="text-foreground text-2xl font-bold">
Join beenvoice
</h1>
<p className="text-muted-foreground mt-2">
Create your account to get started
</p>
</div>
</div>
{/* Registration Form */}
<Card className="auth-card">
<Card className="card-primary">
<CardHeader className="space-y-1">
<CardTitle className="auth-card-title">Create Account</CardTitle>
<CardTitle className="text-center text-xl">
Create Account
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleRegister} className="auth-form">
<div className="auth-input-grid">
<div className="auth-input-group">
<form onSubmit={handleRegister} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<div className="relative">
<User className="auth-input-icon" />
<User className="form-icon-left" />
<Input
id="firstName"
type="text"
@@ -84,10 +90,10 @@ function RegisterForm() {
/>
</div>
</div>
<div className="auth-input-group">
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<div className="relative">
<User className="auth-input-icon" />
<User className="form-icon-left" />
<Input
id="lastName"
type="text"
@@ -100,10 +106,10 @@ function RegisterForm() {
</div>
</div>
</div>
<div className="auth-input-group">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="auth-input-icon" />
<Mail className="form-icon-left" />
<Input
id="email"
type="email"
@@ -115,10 +121,10 @@ function RegisterForm() {
/>
</div>
</div>
<div className="auth-input-group">
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="auth-input-icon" />
<Lock className="form-icon-left" />
<Input
id="password"
type="password"
@@ -130,15 +136,11 @@ function RegisterForm() {
placeholder="Create a password"
/>
</div>
<p className="auth-password-help">
<p className="text-muted-foreground text-xs">
Must be at least 6 characters
</p>
</div>
<Button
type="submit"
className="auth-submit-btn"
disabled={loading}
>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
"Creating account..."
) : (
@@ -149,11 +151,11 @@ function RegisterForm() {
)}
</Button>
</form>
<div className="auth-footer-text">
<div className="mt-6 text-center text-sm">
<span className="text-muted-foreground">
Already have an account?{" "}
</span>
<Link href="/auth/signin" className="auth-footer-link">
<Link href="/auth/signin" className="nav-link-brand">
Sign in here
</Link>
</div>
@@ -161,9 +163,9 @@ function RegisterForm() {
</Card>
{/* Features */}
<div className="auth-features">
<div className="space-y-4 text-center">
<p className="welcome-description">Start invoicing like a pro</p>
<div className="auth-features-list">
<div className="welcome-feature-list">
<span> Free to start</span>
<span> No credit card</span>
<span> Cancel anytime</span>
@@ -178,13 +180,15 @@ export default function RegisterPage() {
return (
<Suspense
fallback={
<div className="auth-container">
<div className="auth-form-container">
<div className="auth-header">
<div className="floating-orbs 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="auth-title">Join beenvoice</h1>
<p className="auth-subtitle">Loading...</p>
<h1 className="text-foreground text-2xl font-bold">
Join beenvoice
</h1>
<p className="text-muted-foreground mt-2">Loading...</p>
</div>
</div>
</div>

View File

@@ -42,7 +42,7 @@ function SignInForm() {
}
return (
<div className="bg-gradient-auth flex min-h-screen items-center justify-center p-4">
<div className="floating-orbs 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">
@@ -135,7 +135,7 @@ export default function SignInPage() {
return (
<Suspense
fallback={
<div className="bg-gradient-auth flex min-h-screen items-center justify-center p-4">
<div className="floating-orbs 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" />

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>
);
}

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>
</>
);
}

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>
);
}

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>
);
}

View File

@@ -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="flex items-center gap-2 rounded-lg bg-red-50 p-3 text-red-800 dark:bg-red-900/20 dark:text-red-300">
<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="rounded-lg bg-amber-50 p-3 text-amber-800 dark:bg-amber-900/20 dark:text-amber-300">
<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>
);
}

View File

@@ -226,8 +226,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>

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>
);
}

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() {

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 }>;
@@ -222,7 +224,7 @@ 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 flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-gray-200/70 dark:hover:bg-gray-700/60"
>
<div>
<p className="text-foreground font-medium">
@@ -238,15 +240,29 @@ export default async function ClientDetailPage({
</p>
<Badge
variant={
invoice.status === "paid"
getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) === "paid"
? "default"
: invoice.status === "sent"
: getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) === "sent"
? "secondary"
: "outline"
: getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) === "overdue"
? "destructive"
: "outline"
}
className="text-xs"
>
{invoice.status}
{getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
)}
</Badge>
</div>
</div>

View File

@@ -96,7 +96,7 @@ export function ClientsDataTable({
<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",
@@ -148,9 +148,9 @@ export function ClientsDataTable({
return (
<div className="flex items-center justify-end gap-1">
<Link href={`/dashboard/clients/${client.id}/edit`}>
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
data-action-button="true"
>
@@ -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>

View File

@@ -1,10 +1,9 @@
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 (

View File

@@ -4,27 +4,68 @@ import { useState } from "react";
import { Button } from "~/components/ui/button";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { Send, Loader2 } from "lucide-react";
interface SendInvoiceButtonProps {
invoiceId: string;
variant?: "default" | "outline" | "ghost" | "icon";
className?: string;
showResend?: boolean;
}
export function SendInvoiceButton({
invoiceId,
variant = "outline",
className,
showResend = false,
}: SendInvoiceButtonProps) {
const [isSending, setIsSending] = useState(false);
// Fetch invoice data when sending is triggered
const { refetch: fetchInvoice } = api.invoices.getById.useQuery(
{ id: invoiceId },
{ enabled: false },
);
// Get utils for cache invalidation
const utils = api.useUtils();
// Use the new email API mutation
const sendInvoiceMutation = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
// Show detailed success message with delivery info
toast.success(data.message, {
description: `Email ID: ${data.emailId}`,
duration: 5000,
});
// Refresh invoice data to show updated status
void utils.invoices.getById.invalidate({ id: invoiceId });
},
onError: (error) => {
// Enhanced error handling with specific error types
console.error("Email send error:", error);
let errorMessage = "Failed to send invoice email";
let errorDescription = "";
if (error.message.includes("Invalid recipient")) {
errorMessage = "Invalid Email Address";
errorDescription =
"Please check the client's email address and try again.";
} else if (error.message.includes("domain not verified")) {
errorMessage = "Email Configuration Issue";
errorDescription = "Please contact support to configure email sending.";
} else if (error.message.includes("rate limit")) {
errorMessage = "Too Many Emails";
errorDescription = "Please wait a moment before sending another email.";
} else if (error.message.includes("no email address")) {
errorMessage = "No Email Address";
errorDescription = "This client doesn't have an email address on file.";
} else {
errorDescription = error.message;
}
toast.error(errorMessage, {
description: errorDescription,
duration: 6000,
});
},
});
const handleSendInvoice = async () => {
if (isSending) return;
@@ -32,88 +73,12 @@ export function SendInvoiceButton({
setIsSending(true);
try {
// Fetch fresh invoice data
const { data: invoice } = await fetchInvoice();
if (!invoice) {
throw new Error("Invoice not found");
}
// Generate PDF blob for potential attachment
const pdfBlob = await generateInvoicePDFBlob(invoice);
// Create a temporary download URL for the PDF
const pdfUrl = URL.createObjectURL(pdfBlob);
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
// Format date
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
};
// Calculate days until due
const today = new Date();
const dueDate = new Date(invoice.dueDate);
const daysUntilDue = Math.ceil(
(dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
);
// Create professional email template
const subject = `Invoice ${invoice.invoiceNumber} - ${formatCurrency(invoice.totalAmount)}`;
const body = `Dear ${invoice.client.name},
I hope this email finds you well. Please find attached invoice ${invoice.invoiceNumber} for the services provided.
Invoice Details:
• Invoice Number: ${invoice.invoiceNumber}
• Issue Date: ${formatDate(invoice.issueDate)}
• Due Date: ${formatDate(invoice.dueDate)}
• Amount Due: ${formatCurrency(invoice.totalAmount)}
${daysUntilDue > 0 ? `• Payment Due: In ${daysUntilDue} days` : daysUntilDue === 0 ? `• Payment Due: Today` : `• Status: ${Math.abs(daysUntilDue)} days overdue`}
${invoice.notes ? `\nAdditional Notes:\n${invoice.notes}\n` : ""}
Please review the attached invoice and remit payment by the due date. If you have any questions or concerns regarding this invoice, please don't hesitate to contact me.
Thank you for your business!
Best regards,
${invoice.business?.name ?? "Your Business Name"}
${invoice.business?.email ? `\n${invoice.business.email}` : ""}
${invoice.business?.phone ? `\n${invoice.business.phone}` : ""}`;
// Create mailto link
const mailtoLink = `mailto:${invoice.client.email ?? ""}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
// Create a temporary link element to trigger mailto
const link = document.createElement("a");
link.href = mailtoLink;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the PDF URL object
URL.revokeObjectURL(pdfUrl);
toast.success("Email client opened with invoice details");
await sendInvoiceMutation.mutateAsync({
invoiceId,
});
} catch (error) {
// Error is already handled by the mutation's onError
console.error("Send invoice error:", error);
toast.error(
error instanceof Error
? error.message
: "Failed to prepare invoice email",
);
} finally {
setIsSending(false);
}
@@ -149,12 +114,12 @@ ${invoice.business?.phone ? `\n${invoice.business.phone}` : ""}`;
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Preparing Email...</span>
<span>Sending Email...</span>
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
<span>Send Invoice</span>
<span>{showResend ? "Resend Invoice" : "Send Invoice"}</span>
</>
)}
</Button>

View File

@@ -0,0 +1,511 @@
"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 { 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 rounded-lg" />
</div>
<div className="space-y-6">
<div className="bg-muted h-64 animate-pulse rounded-lg" />
</div>
</div>
</div>
);
}
export default function SendEmailPage() {
const params = useParams();
const router = useRouter();
const invoiceId = params.id as string;
// State management
const [activeTab, setActiveTab] = useState("compose");
const [isSending, setIsSending] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
// 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}/view`);
// Refresh invoice data
void utils.invoices.getById.invalidate({ id: invoiceId });
},
onError: (error) => {
console.error("Email send error:", error);
let errorMessage = "Failed to send invoice email";
let errorDescription = error.message;
if (error.message.includes("Invalid recipient")) {
errorMessage = "Invalid Email Address";
errorDescription =
"Please check the client's email address and try again.";
} else if (error.message.includes("domain not verified")) {
errorMessage = "Email Configuration Issue";
errorDescription = "Please contact support to configure email sending.";
} else if (error.message.includes("rate limit")) {
errorMessage = "Too Many Emails";
errorDescription = "Please wait a moment before sending another email.";
} else if (error.message.includes("no email address")) {
errorMessage = "No Email Address";
errorDescription = "This client doesn't have an email address on file.";
}
toast.error(errorMessage, {
description: errorDescription,
duration: 6000,
});
setIsSending(false);
},
});
// Transform invoice data for components
const invoice = useMemo(() => {
return invoiceData
? {
id: invoiceData.id,
invoiceNumber: invoiceData.invoiceNumber,
issueDate: invoiceData.issueDate,
dueDate: invoiceData.dueDate,
status: invoiceData.status,
taxRate: invoiceData.taxRate,
client: invoiceData.client
? {
name: invoiceData.client.name,
email: invoiceData.client.email,
}
: undefined,
business: invoiceData.business
? {
name: invoiceData.business.name,
email: invoiceData.business.email,
}
: undefined,
items: invoiceData.items?.map((item) => ({
id: item.id,
hours: item.hours,
rate: item.rate,
})),
}
: undefined;
}, [invoiceData]);
// Initialize email content when invoice loads
useEffect(() => {
if (!invoice || isInitialized) return;
// Set default subject
const defaultSubject = `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`;
setSubject(defaultSubject);
// Set default content (empty since template handles everything)
const defaultContent = ``;
setEmailContent(defaultContent);
setIsInitialized(true);
}, [invoice, isInitialized]);
const handleSendEmail = async () => {
if (!invoice?.client?.email || invoice.client.email.trim() === "") {
toast.error("No email address", {
description: "This client doesn't have an email address on file.",
});
return;
}
if (!subject.trim()) {
toast.error("Subject required", {
description: "Please enter an email subject before sending.",
});
return;
}
// Email content is now optional since template handles default messaging
setIsSending(true);
try {
await sendEmailMutation.mutateAsync({
invoiceId,
customSubject: subject,
customContent: emailContent,
customMessage: customMessage?.trim() || undefined,
useHtml: true,
ccEmails: ccEmail.trim() || undefined,
bccEmails: bccEmail.trim() || undefined,
});
} catch (error) {
// Error handling is done in the mutation's onError
console.error("Send email error:", error);
}
};
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com";
const toEmail = invoice?.client?.email ?? "";
const canSend =
!isSending && subject.trim() && toEmail && toEmail.trim() !== "";
if (invoiceLoading) {
return <SendEmailPageSkeleton />;
}
if (!invoice) {
return (
<div className="container mx-auto max-w-4xl p-6">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>Invoice not found.</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="container mx-auto max-w-6xl space-y-6 pb-32">
<PageHeader
title={`Send Invoice ${invoice.invoiceNumber}`}
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
},
).format(new Date())}`}
variant="gradient"
>
<Button
variant="outline"
onClick={() => router.push(`/dashboard/invoices/${invoiceId}/view`)}
>
<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 rounded-md border">
<div className="text-center">
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div>
<p className="text-muted-foreground text-sm">
Initializing email content...
</p>
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="preview" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Email Preview
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
bccEmail={bccEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
className="min-w-0 border-0"
/>
</div>
</CardContent>
</Card>
</TabsContent>
</div>
</Tabs>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Invoice Summary */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="h-5 w-5 text-green-600" />
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="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
<Send className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">
Send Invoice
</p>
<p className="text-sm text-gray-600 dark:text-gray-300">
Email invoice to {invoice.client?.name ?? "client"}
</p>
</div>
</div>
}
>
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/dashboard/invoices/${invoiceId}/view`)}
>
Cancel
</Button>
<Button
onClick={handleSendEmail}
disabled={!canSend || isSending}
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
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>
</div>
);
}

View File

@@ -1,17 +1,14 @@
"use client";
import { useState } from "react";
import { notFound, useRouter, useParams } from "next/navigation";
import { DollarSign, Edit, Loader2, Trash2 } from "lucide-react";
import Link from "next/link";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { notFound, useParams, useRouter } from "next/navigation";
import { useState } 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 { InvoiceDetailsSkeleton } from "../_components/invoice-details-skeleton";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
Dialog,
DialogContent,
@@ -20,19 +17,26 @@ import {
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { toast } from "sonner";
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,
Trash2,
} from "lucide-react";
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
@@ -42,8 +46,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
id: invoiceId,
});
const utils = api.useUtils();
// Delete mutation
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
toast.success("Invoice deleted successfully");
@@ -54,10 +58,27 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
},
});
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 });
};
@@ -88,17 +109,17 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = (subtotal * invoice.taxRate) / 100;
const total = subtotal + taxAmount;
const isOverdue =
new Date(invoice.dueDate) < new Date() && invoice.status !== "paid";
const effectiveStatus = getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
const isOverdue = isInvoiceOverdue(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
const getStatusType = (): StatusType => {
if (invoice.status === "paid") return "paid";
if (invoice.status === "draft") return "draft";
if (invoice.status === "overdue") return "overdue";
if (invoice.status === "sent") {
return isOverdue ? "overdue" : "sent";
}
return "draft";
return effectiveStatus as StatusType;
};
return (
@@ -401,8 +422,38 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<PDFDownloadButton invoiceId={invoice.id} className="w-full" />
)}
{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"
/>
)}
{(effectiveStatus === "sent" ||
effectiveStatus === "overdue") && (
<EnhancedSendInvoiceButton
invoiceId={invoice.id}
className="w-full"
showResend={true}
/>
)}
{/* Manual Status Updates */}
{(effectiveStatus === "sent" ||
effectiveStatus === "overdue") && (
<Button
onClick={handleMarkAsPaid}
disabled={updateStatus.isPending}
className="w-full bg-green-600 text-white hover:bg-green-700"
>
{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

View File

@@ -19,6 +19,8 @@ import {
import { Eye, Edit, Trash2 } from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
// Type for invoice data
interface Invoice {
@@ -65,14 +67,10 @@ interface InvoicesDataTableProps {
}
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";
return getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) as StatusType;
};
const formatDate = (date: Date) => {

View File

@@ -1,21 +1,20 @@
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() {

View File

@@ -1,25 +1,26 @@
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,
Activity,
ArrowUpRight,
BarChart3,
Calendar,
Clock,
Eye,
DollarSign,
Edit,
Activity,
BarChart3,
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 "~/server/auth";
import { HydrateClient, api } from "~/trpc/server";
import type { StoredInvoiceStatus } from "~/types/invoice";
// Modern gradient background component
function DashboardHero({ firstName }: { firstName: string }) {
@@ -46,10 +47,22 @@ async function DashboardStats() {
const totalClients = clients.length;
const totalInvoices = invoices.length;
const totalRevenue = invoices
.filter((invoice) => invoice.status === "paid")
.filter(
(invoice) =>
getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) === "paid",
)
.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
const pendingAmount = invoices
.filter((invoice) => invoice.status === "sent")
.filter((invoice) => {
const effectiveStatus = getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
return effectiveStatus === "sent" || effectiveStatus === "overdue";
})
.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
const stats = [
@@ -197,7 +210,11 @@ function QuickActions() {
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];

View File

@@ -16,6 +16,7 @@ import {
Key,
Eye,
EyeOff,
FileUp,
} from "lucide-react";
import { api } from "~/trpc/react";
@@ -59,6 +60,7 @@ export function SettingsContent() {
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [importData, setImportData] = useState("");
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const [importMethod, setImportMethod] = useState<"file" | "paste">("file");
// Password change state
const [currentPassword, setCurrentPassword] = useState("");
@@ -182,76 +184,60 @@ export function SettingsContent() {
};
// Type guard for backup data
const isValidBackupData = (
data: unknown,
): data is {
exportDate: string;
version: string;
user: { name?: string; email: string };
clients: Array<{
name: string;
email?: string;
phone?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
}>;
businesses: Array<{
name: string;
email?: string;
phone?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
website?: string;
taxId?: string;
logoUrl?: string;
isDefault?: boolean;
}>;
invoices: Array<{
invoiceNumber: string;
businessName?: string;
clientName: string;
issueDate: Date;
dueDate: Date;
status?: string;
totalAmount?: number;
taxRate?: number;
notes?: string;
items: Array<{
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
position?: number;
}>;
}>;
} => {
const isValidBackupData = (data: unknown): boolean => {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
return !!(
data &&
typeof data === "object" &&
data !== null &&
"exportDate" in data &&
"version" in data &&
"user" in data &&
"clients" in data &&
"businesses" in data &&
"invoices" in data
obj.exportDate &&
obj.version &&
obj.user &&
obj.clients &&
obj.businesses &&
obj.invoices &&
Array.isArray(obj.clients) &&
Array.isArray(obj.businesses) &&
Array.isArray(obj.invoices)
);
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.endsWith(".json")) {
toast.error("Please select a JSON file");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target?.result as string;
const parsedData: unknown = JSON.parse(content);
if (isValidBackupData(parsedData)) {
// @ts-expect-error Server handles validation of backup data format
importDataMutation.mutate(parsedData);
} else {
toast.error("Invalid backup file format");
}
} catch {
toast.error("Invalid JSON format. Please check your backup file.");
}
};
reader.onerror = () => {
toast.error("Failed to read file");
};
reader.readAsText(file);
};
const handleImportData = () => {
try {
const parsedData: unknown = JSON.parse(importData);
if (isValidBackupData(parsedData)) {
// @ts-expect-error Server handles validation of backup data format
importDataMutation.mutate(parsedData);
} else {
toast.error("Invalid backup file format");
@@ -536,37 +522,95 @@ export function SettingsContent() {
<DialogHeader>
<DialogTitle>Import Backup Data</DialogTitle>
<DialogDescription>
Paste the contents of your backup JSON file below. This
will add the data to your existing account.
Upload your backup JSON file or paste the contents below.
This will add the data to your existing account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Textarea
placeholder="Paste your backup JSON data here..."
value={importData}
onChange={(e) => setImportData(e.target.value)}
rows={12}
className="font-mono text-sm"
/>
{/* Import Method Selector */}
<div className="flex gap-2">
<Button
type="button"
variant={
importMethod === "file" ? "default" : "outline"
}
size="sm"
onClick={() => setImportMethod("file")}
className="flex-1"
>
<FileUp className="mr-2 h-4 w-4" />
Upload File
</Button>
<Button
type="button"
variant={
importMethod === "paste" ? "default" : "outline"
}
size="sm"
onClick={() => setImportMethod("paste")}
className="flex-1"
>
<FileText className="mr-2 h-4 w-4" />
Paste Content
</Button>
</div>
{/* File Upload Method */}
{importMethod === "file" && (
<div className="space-y-2">
<Label htmlFor="backup-file">Select Backup File</Label>
<Input
id="backup-file"
type="file"
accept=".json"
onChange={handleFileUpload}
disabled={importDataMutation.isPending}
/>
<p className="text-muted-foreground text-sm">
Select the JSON backup file you previously exported.
</p>
</div>
)}
{/* Manual Paste Method */}
{importMethod === "paste" && (
<div className="space-y-2">
<Label htmlFor="backup-content">Backup Content</Label>
<Textarea
id="backup-content"
placeholder="Paste your backup JSON data here..."
value={importData}
onChange={(e) => setImportData(e.target.value)}
rows={12}
className="font-mono text-sm"
/>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsImportDialogOpen(false)}
onClick={() => {
setIsImportDialogOpen(false);
setImportData("");
setImportMethod("file");
}}
>
Cancel
</Button>
<Button
onClick={handleImportData}
disabled={
!importData.trim() || importDataMutation.isPending
}
className="btn-brand-primary"
>
{importDataMutation.isPending
? "Importing..."
: "Import Data"}
</Button>
{importMethod === "paste" && (
<Button
onClick={handleImportData}
disabled={
!importData.trim() || importDataMutation.isPending
}
className="btn-brand-primary"
>
{importDataMutation.isPending
? "Importing..."
: "Import Data"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
@@ -581,6 +625,7 @@ export function SettingsContent() {
<li>
Import adds to existing data without replacing anything
</li>
<li> Upload JSON files directly or paste content manually</li>
<li> Store backup files in a secure, accessible location</li>
</ul>
</div>

View File

@@ -1,202 +0,0 @@
"use client";
import { useState } from "react";
import { DataTable } from "~/components/data/data-table";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { Plus } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTableColumnHeader } from "~/components/data/data-table";
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
import Link from "next/link";
// Sample data type
interface DemoItem {
id: string;
name: string;
email: string;
phone: string;
status: string;
createdAt: Date;
}
// Generate sample data
const sampleData: DemoItem[] = Array.from({ length: 50 }, (_, i) => ({
id: `item-${i + 1}`,
name: `Item ${i + 1}`,
email: `item${i + 1}@example.com`,
phone: `555-${String(Math.floor(Math.random() * 9000) + 1000)}`,
status: ["active", "pending", "inactive"][
Math.floor(Math.random() * 3)
] as string,
createdAt: new Date(Date.now() - Math.random() * 10000000000),
}));
// Define columns with responsive behavior
const columns: ColumnDef<DemoItem>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.name}</p>
<p className="text-muted-foreground text-xs">{row.original.email}</p>
</div>
),
},
{
accessorKey: "phone",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Phone" />
),
cell: ({ row }) => row.original.phone,
meta: {
headerClassName: "hidden md:table-cell",
cellClassName: "hidden md:table-cell",
},
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => (
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
row.original.status === "active"
? "bg-green-100 text-green-700"
: row.original.status === "pending"
? "bg-yellow-100 text-yellow-700"
: "bg-gray-100 text-gray-700"
}`}
>
{row.original.status}
</span>
),
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.getValue("createdAt") as Date;
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
}).format(date);
},
meta: {
headerClassName: "hidden lg:table-cell",
cellClassName: "hidden lg:table-cell",
},
},
];
export default function TableLayoutDemo() {
const [data] = useState(sampleData);
const filterableColumns = [
{
id: "status",
title: "Status",
options: [
{ label: "Active", value: "active" },
{ label: "Pending", value: "pending" },
{ label: "Inactive", value: "inactive" },
],
},
];
return (
<div className="container mx-auto py-10">
<DashboardBreadcrumbs />
<PageHeader
title="Table Layout & Breadcrumb Demo"
description="This demo showcases the improved responsive layouts and dynamic breadcrumbs. The breadcrumbs automatically handle pluralization and capitalization. Navigate to different pages to see how they adapt."
variant="gradient"
>
<Button variant="brand" size="lg">
<Plus className="mr-2 h-5 w-5" />
Add Item
</Button>
</PageHeader>
<div className="mt-8">
<DataTable
columns={columns}
data={data}
searchPlaceholder="Search items..."
filterableColumns={filterableColumns}
showColumnVisibility={true}
showPagination={true}
showSearch={true}
pageSize={10}
/>
</div>
<div className="mt-16 space-y-4">
<h2 className="text-2xl font-bold">Layout Improvements</h2>
<div className="text-muted-foreground space-y-2 text-sm">
<p>
Page header: Description text wraps below the title and action
buttons
</p>
<p>
Filter bar: Search and filters stay inline on mobile with proper
wrapping
</p>
<p>
Pagination bar: Entry count and controls remain on the same line
on mobile
</p>
<p>
Columns: Responsive hiding with both headers and cells hidden
together
</p>
<p>
Compact design: Tighter padding for more efficient space usage
</p>
</div>
<h2 className="mt-8 text-2xl font-bold">Dynamic Breadcrumbs</h2>
<div className="text-muted-foreground space-y-2 text-sm">
<p>
Automatic pluralization: "Business" becomes "Businesses" on list
pages
</p>
<p> Smart capitalization: Route segments are properly capitalized</p>
<p> Context awareness: Shows resource names instead of UUIDs</p>
<p>
Clean presentation: Edit pages show the resource name, not "Edit"
</p>
</div>
<div className="mt-4 space-y-2">
<p className="text-sm font-medium">Try these example routes:</p>
<div className="flex flex-wrap gap-2">
<Link href="/dashboard/businesses">
<Button variant="outline" size="sm">
Businesses List
</Button>
</Link>
<Link href="/dashboard/clients">
<Button variant="outline" size="sm">
Clients List
</Button>
</Link>
<Link href="/dashboard/invoices">
<Button variant="outline" size="sm">
Invoices List
</Button>
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,18 +0,0 @@
import { Navbar } from "~/components/layout/navbar";
import { Sidebar } from "~/components/layout/sidebar";
export default function InvoicesLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Navbar />
<div className="flex">
<Sidebar />
<main className="bg-background min-h-screen flex-1">{children}</main>
</div>
</>
);
}

View File

@@ -1,44 +0,0 @@
import Link from "next/link";
import { auth } from "~/server/auth";
import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { InvoiceList } from "~/components/data/invoice-list";
import { Plus } from "lucide-react";
export default async function InvoicesPage() {
const session = await auth();
if (!session?.user) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">Access Denied</h1>
<p className="text-muted-foreground mb-8">
Please sign in to view invoices
</p>
<Link href="/api/auth/signin">
<Button size="lg">Sign In</Button>
</Link>
</div>
</div>
);
}
// Prefetch invoices data
void api.invoices.getAll.prefetch();
return (
<HydrateClient>
<div className="p-6">
<div className="mb-8">
<h2 className="mb-2 text-3xl font-bold">Invoices</h2>
<p className="text-muted-foreground">
Manage your invoices and payments
</p>
</div>
<InvoiceList />
</div>
</HydrateClient>
);
}

View File

@@ -443,7 +443,7 @@ export default function HomePage() {
</div>
<div className="mt-6 border-t border-slate-200 pt-6 sm:mt-8 sm:pt-8 dark:border-slate-700">
<p className="text-sm text-slate-600 sm:text-base dark:text-slate-400">
&copy; 2025 Sean O'Connor.
&copy; 2025 Sean O&apos;Connor.
</p>
</div>
</div>

View File

@@ -1,5 +1,4 @@
import Image from "next/image";
import { cn } from "~/lib/utils";
interface LogoProps {
className?: string;
@@ -18,8 +17,8 @@ export function Logo({ className, size = "md" }: LogoProps) {
<Image
src="/beenvoice-logo.svg"
alt="beenvoice logo"
width={width}
height={height}
width={width}
height={height}
className={className}
priority
/>

View File

@@ -1,21 +1,12 @@
"use client";
import { Calendar, Clock, Edit, Eye, FileText, Plus, User } from "lucide-react";
import Link from "next/link";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
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 {
FileText,
Clock,
Plus,
Edit,
Eye,
DollarSign,
User,
Calendar,
} from "lucide-react";
import { api } from "~/trpc/react";
export function CurrentOpenInvoiceCard() {
const { data: currentInvoice, isLoading } =

View File

@@ -1,6 +1,5 @@
"use client";
import * as React from "react";
import type {
ColumnDef,
ColumnFiltersState,
@@ -18,23 +17,25 @@ import {
import {
ArrowUpDown,
ChevronDown,
Search,
Filter,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Filter,
Search,
X,
} from "lucide-react";
import * as React from "react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Card, CardContent } from "~/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Input } from "~/components/ui/input";
import {
Select,
SelectContent,
@@ -50,7 +51,6 @@ import {
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Card, CardContent } from "~/components/ui/card";
import { cn } from "~/lib/utils";
interface DataTableProps<TData, TValue> {
@@ -77,7 +77,7 @@ interface DataTableProps<TData, TValue> {
export function DataTable<TData, TValue>({
columns,
data,
searchKey,
searchKey: _searchKey,
searchPlaceholder = "Search...",
showColumnVisibility = true,
showPagination = true,
@@ -511,7 +511,7 @@ export function DataTable<TData, TValue>({
}
// Helper component for sortable column headers
export function DataTableColumnHeader<TData, TValue>({
export function DataTableColumnHeader({
column,
title,
className,
@@ -552,7 +552,7 @@ export function DataTableColumnHeader<TData, TValue>({
// Export skeleton component for loading states
export function DataTableSkeleton({
columns = 5,
columns: _columns = 5,
rows = 5,
}: {
columns?: number;

View File

@@ -89,9 +89,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
deleteInvoice.mutate({ id: invoiceId });
};
const handleStatusUpdate = (
newStatus: "draft" | "sent" | "paid" | "overdue",
) => {
const handleStatusUpdate = (newStatus: "draft" | "sent" | "paid") => {
updateStatus.mutate({ id: invoiceId, status: newStatus });
};

View File

@@ -45,7 +45,7 @@ export function AddressForm({
className = "",
}: AddressFormProps) {
const handlePostalCodeChange = (value: string) => {
const formatted = formatPostalCode(value, country || "US");
const formatted = formatPostalCode(value, country ?? "US");
onChange("postalCode", formatted);
};
@@ -137,7 +137,7 @@ export function AddressForm({
key={`state-${state}`}
id="state"
options={stateOptions}
value={state || ""}
value={state ?? ""}
onValueChange={(value) => onChange("state", value)}
placeholder="Select a state"
className={errors.state ? "border-destructive" : ""}
@@ -194,7 +194,7 @@ export function AddressForm({
key={`country-${country}`}
id="country"
options={countryOptions}
value={country || ""}
value={country ?? ""}
onValueChange={(value) => {
// Don't save the placeholder value
if (value !== "__placeholder__") {
@@ -218,7 +218,8 @@ export function AddressForm({
return option.label;
}}
isOptionDisabled={(option) =>
option.disabled || option.value?.startsWith("divider-")
(option.disabled ?? false) ||
(option.value?.startsWith("divider-") ?? false)
}
/>
{errors.country && (

View File

@@ -1,40 +1,43 @@
"use client";
import {
Building,
Mail,
Phone,
Save,
Globe,
BadgeDollarSign,
Image,
Star,
Loader2,
ArrowLeft,
Building,
Eye,
EyeOff,
FileText,
Globe,
Info,
Key,
Loader2,
Mail,
Save,
Star,
User,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Skeleton } from "~/components/ui/skeleton";
import { Switch } from "~/components/ui/switch";
import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header";
import { api } from "~/trpc/react";
import {
formatPhoneNumber,
formatWebsiteUrl,
formatTaxId,
formatWebsiteUrl,
isValidEmail,
VALIDATION_MESSAGES,
PLACEHOLDERS,
VALIDATION_MESSAGES,
} from "~/lib/form-constants";
import { api } from "~/trpc/react";
interface BusinessFormProps {
businessId?: string;
@@ -53,8 +56,10 @@ interface FormData {
country: string;
website: string;
taxId: string;
logoUrl: string;
isDefault: boolean;
resendApiKey: string;
resendDomain: string;
emailFromName: string;
}
interface FormErrors {
@@ -68,6 +73,9 @@ interface FormErrors {
country?: string;
website?: string;
taxId?: string;
resendApiKey?: string;
resendDomain?: string;
emailFromName?: string;
}
const initialFormData: FormData = {
@@ -82,8 +90,10 @@ const initialFormData: FormData = {
country: "United States",
website: "",
taxId: "",
logoUrl: "",
isDefault: false,
resendApiKey: "",
resendDomain: "",
emailFromName: "",
};
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
@@ -91,6 +101,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const [formData, setFormData] = useState<FormData>(initialFormData);
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [isDirty, setIsDirty] = useState(false);
// Fetch business data if editing
@@ -100,6 +111,23 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
{ enabled: mode === "edit" && !!businessId },
);
// Fetch email configuration if editing
const { data: emailConfig, isLoading: isLoadingEmailConfig } =
api.businesses.getEmailConfig.useQuery(
{ id: businessId! },
{ enabled: mode === "edit" && !!businessId },
);
// Update email configuration mutation
const updateEmailConfig = api.businesses.updateEmailConfig.useMutation({
onSuccess: () => {
toast.success("Email configuration updated successfully");
},
onError: (error) => {
toast.error(`Failed to update email configuration: ${error.message}`);
},
});
const createBusiness = api.businesses.create.useMutation({
onSuccess: () => {
toast.success("Business created successfully");
@@ -135,11 +163,13 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
country: business.country ?? "United States",
website: business.website ?? "",
taxId: business.taxId ?? "",
logoUrl: business.logoUrl ?? "",
isDefault: business.isDefault ?? false,
resendApiKey: "", // Never pre-fill API key for security
resendDomain: emailConfig?.resendDomain ?? "",
emailFromName: emailConfig?.emailFromName ?? "",
});
}
}, [business, mode]);
}, [business, emailConfig, mode]);
const handleInputChange = (field: string, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
@@ -202,6 +232,36 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
}
}
// Email configuration validation
// API Key validation
if (formData.resendApiKey && !formData.resendApiKey.startsWith("re_")) {
newErrors.resendApiKey = "Resend API key should start with 're_'";
}
// Domain validation
if (formData.resendDomain) {
const domainRegex =
/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?\.([a-zA-Z]{2,})+$/;
if (!domainRegex.test(formData.resendDomain)) {
newErrors.resendDomain =
"Please enter a valid domain (e.g., yourdomain.com)";
}
}
// If API key is provided, domain must also be provided
if (formData.resendApiKey && !formData.resendDomain) {
newErrors.resendDomain = "Domain is required when API key is provided";
}
// If domain is provided, API key must also be provided
if (
formData.resendDomain &&
!formData.resendApiKey &&
!emailConfig?.hasApiKey
) {
newErrors.resendApiKey = "API key is required when domain is provided";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@@ -224,12 +284,73 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
};
if (mode === "create") {
await createBusiness.mutateAsync(dataToSubmit);
// Create business data (excluding email config fields)
const businessData = {
name: dataToSubmit.name,
email: dataToSubmit.email,
phone: dataToSubmit.phone,
addressLine1: dataToSubmit.addressLine1,
addressLine2: dataToSubmit.addressLine2,
city: dataToSubmit.city,
state: dataToSubmit.state,
postalCode: dataToSubmit.postalCode,
country: dataToSubmit.country,
website: dataToSubmit.website,
taxId: dataToSubmit.taxId,
isDefault: dataToSubmit.isDefault,
};
const newBusiness = await createBusiness.mutateAsync(businessData);
// Update email configuration separately if any email fields are provided
if (
newBusiness &&
(formData.resendApiKey ||
formData.resendDomain ||
formData.emailFromName)
) {
await updateEmailConfig.mutateAsync({
id: newBusiness.id,
resendApiKey: formData.resendApiKey || undefined,
resendDomain: formData.resendDomain || undefined,
emailFromName: formData.emailFromName || undefined,
});
}
} else {
// Update business data (excluding email config fields)
const businessData = {
name: dataToSubmit.name,
email: dataToSubmit.email,
phone: dataToSubmit.phone,
addressLine1: dataToSubmit.addressLine1,
addressLine2: dataToSubmit.addressLine2,
city: dataToSubmit.city,
state: dataToSubmit.state,
postalCode: dataToSubmit.postalCode,
country: dataToSubmit.country,
website: dataToSubmit.website,
taxId: dataToSubmit.taxId,
isDefault: dataToSubmit.isDefault,
};
await updateBusiness.mutateAsync({
id: businessId!,
...dataToSubmit,
...businessData,
});
// Update email configuration separately if any email fields are provided
if (
formData.resendApiKey ||
formData.resendDomain ||
formData.emailFromName
) {
await updateEmailConfig.mutateAsync({
id: businessId!,
resendApiKey: formData.resendApiKey || undefined,
resendDomain: formData.resendDomain || undefined,
emailFromName: formData.emailFromName || undefined,
});
}
}
} finally {
setIsSubmitting(false);
@@ -246,7 +367,10 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
router.push("/dashboard/businesses");
};
if (mode === "edit" && isLoadingBusiness) {
if (
(mode === "edit" && isLoadingBusiness) ||
(mode === "edit" && isLoadingEmailConfig)
) {
return (
<div className="space-y-6 pb-32">
<Card>
@@ -488,6 +612,189 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</CardContent>
</Card>
{/* Email Configuration */}
<Card className="card-primary">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-brand-muted flex h-10 w-10 items-center justify-center rounded-lg">
<Mail className="text-brand-light h-5 w-5" />
</div>
<div>
<CardTitle>Email Configuration</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
Configure your own Resend API key and domain for sending
invoices
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Current Status */}
{mode === "edit" && (
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
Current Status:
</span>
{emailConfig?.hasApiKey && emailConfig?.resendDomain ? (
<Badge
variant="default"
className="bg-green-100 text-green-800"
>
<Key className="mr-1 h-3 w-3" />
Custom Configuration Active
</Badge>
) : (
<Badge variant="secondary">
<Globe className="mr-1 h-3 w-3" />
Using System Default
</Badge>
)}
</div>
{emailConfig?.resendDomain && (
<span className="text-sm text-gray-600">
Domain: {emailConfig.resendDomain}
</span>
)}
</div>
)}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
To use your own email configuration, you&apos;ll need to:
<ul className="mt-2 list-inside list-disc space-y-1">
<li>
Create a free account at{" "}
<a
href="https://resend.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
resend.com
</a>
</li>
<li>Verify your domain in the Resend dashboard</li>
<li>Get your API key from the Resend dashboard</li>
</ul>
</AlertDescription>
</Alert>
<div className="space-y-4">
{/* API Key */}
<div className="space-y-2">
<Label
htmlFor="resendApiKey"
className="flex items-center gap-2"
>
<Key className="h-4 w-4" />
Resend API Key
{mode === "edit" && emailConfig?.hasApiKey && (
<Badge variant="outline" className="text-xs">
Currently Set
</Badge>
)}
</Label>
<div className="relative">
<Input
id="resendApiKey"
type={showApiKey ? "text" : "password"}
value={formData.resendApiKey}
onChange={(e) =>
handleInputChange("resendApiKey", e.target.value)
}
placeholder={
mode === "edit" && emailConfig?.hasApiKey
? "Enter new API key to update"
: "re_..."
}
className={errors.resendApiKey ? "border-red-500" : ""}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 p-0"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
{errors.resendApiKey && (
<p className="text-sm text-red-600">
{errors.resendApiKey}
</p>
)}
</div>
{/* Domain */}
<div className="space-y-2">
<Label
htmlFor="resendDomain"
className="flex items-center gap-2"
>
<Globe className="h-4 w-4" />
Verified Domain
</Label>
<Input
id="resendDomain"
type="text"
value={formData.resendDomain}
onChange={(e) =>
handleInputChange("resendDomain", e.target.value)
}
placeholder="yourdomain.com"
className={errors.resendDomain ? "border-red-500" : ""}
/>
{errors.resendDomain && (
<p className="text-sm text-red-600">
{errors.resendDomain}
</p>
)}
<p className="text-sm text-gray-600">
This domain must be verified in your Resend account before
emails can be sent.
</p>
</div>
{/* From Name */}
<div className="space-y-2">
<Label
htmlFor="emailFromName"
className="flex items-center gap-2"
>
<User className="h-4 w-4" />
From Name (Optional)
</Label>
<Input
id="emailFromName"
type="text"
value={formData.emailFromName}
onChange={(e) =>
handleInputChange("emailFromName", e.target.value)
}
placeholder={formData.name || "Your Business Name"}
className={errors.emailFromName ? "border-red-500" : ""}
/>
{errors.emailFromName && (
<p className="text-sm text-red-600">
{errors.emailFromName}
</p>
)}
<p className="text-sm text-gray-600">
This will appear as the sender name in emails. Defaults to
your business name.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Settings */}
<Card className="card-primary">
<CardHeader>

View File

@@ -0,0 +1,345 @@
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { TextAlign } from "@tiptap/extension-text-align";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Separator } from "~/components/ui/separator";
import { useEffect } from "react";
import {
Bold,
Italic,
Underline,
List,
ListOrdered,
AlignLeft,
AlignCenter,
AlignRight,
Palette,
} from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
interface EmailComposerProps {
subject: string;
onSubjectChange: (subject: string) => void;
content?: string;
onContentChange?: (content: string) => void;
customMessage?: string;
onCustomMessageChange?: (customMessage: string) => void;
fromEmail: string;
toEmail: string;
ccEmail?: string;
onCcEmailChange?: (ccEmail: string) => void;
bccEmail?: string;
onBccEmailChange?: (bccEmail: string) => void;
className?: string;
}
const MenuButton = ({
onClick,
isActive,
children,
title,
}: {
onClick: () => void;
isActive?: boolean;
children: React.ReactNode;
title: string;
}) => (
<Button
type="button"
variant={isActive ? "default" : "ghost"}
size="sm"
onClick={onClick}
title={title}
className="h-8 w-8 p-0"
>
{children}
</Button>
);
export function EmailComposer({
subject,
onSubjectChange,
content: _content,
onContentChange: _onContentChange,
customMessage = "",
onCustomMessageChange,
fromEmail,
toEmail,
ccEmail = "",
onCcEmailChange,
bccEmail = "",
onBccEmailChange,
className,
}: EmailComposerProps) {
const editor = useEditor({
extensions: [
StarterKit,
TextStyle,
Color.configure({
types: ["textStyle"],
}),
TextAlign.configure({
types: ["heading", "paragraph"],
}),
],
content: customMessage,
immediatelyRender: false,
onUpdate: ({ editor }) => {
onCustomMessageChange?.(editor.getHTML());
},
editorProps: {
attributes: {
class:
"prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none min-h-[120px] p-4 border rounded-md bg-background",
},
},
});
// Update editor content when customMessage prop changes
useEffect(() => {
if (editor && customMessage !== undefined) {
const currentContent = editor.getHTML();
if (currentContent !== customMessage) {
editor.commands.setContent(customMessage);
}
}
}, [editor, customMessage]);
const colors = [
"#000000",
"#374151",
"#DC2626",
"#EA580C",
"#D97706",
"#65A30D",
"#16A34A",
"#0891B2",
"#2563EB",
"#7C3AED",
"#C026D3",
"#DC2626",
];
if (!editor) {
return (
<div className="bg-muted flex h-[200px] items-center justify-center rounded-md border">
<div className="text-center">
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div>
<p className="text-muted-foreground text-sm">Loading editor...</p>
</div>
</div>
);
}
return (
<div className={className}>
{/* Email Headers */}
<div className="bg-muted/20 space-y-4 rounded-lg border p-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="from-email" className="text-sm font-medium">
From
</Label>
<Input
id="from-email"
value={fromEmail}
disabled
className="bg-muted text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label htmlFor="to-email" className="text-sm font-medium">
To
</Label>
<Input
id="to-email"
value={toEmail}
disabled
className="bg-muted text-muted-foreground"
/>
</div>
</div>
{(onCcEmailChange ?? onBccEmailChange) && (
<div className="grid grid-cols-2 gap-4">
{onCcEmailChange && (
<div className="space-y-2">
<Label htmlFor="cc-email" className="text-sm font-medium">
CC
</Label>
<Input
id="cc-email"
value={ccEmail ?? ""}
onChange={(e) => onCcEmailChange(e.target.value)}
placeholder="CC email addresses..."
className="bg-background"
/>
</div>
)}
{onBccEmailChange && (
<div className="space-y-2">
<Label htmlFor="bcc-email" className="text-sm font-medium">
BCC
</Label>
<Input
id="bcc-email"
value={bccEmail}
onChange={(e) => onBccEmailChange(e.target.value)}
placeholder="BCC email addresses..."
className="bg-background"
/>
</div>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="subject" className="text-sm font-medium">
Subject
</Label>
<Input
id="subject"
value={subject}
onChange={(e) => onSubjectChange(e.target.value)}
placeholder="Enter email subject..."
className="bg-background"
/>
</div>
</div>
<Separator className="my-4" />
{/* Custom Message Field with Rich Text Editor */}
{onCustomMessageChange && (
<div className="space-y-4">
<div>
<Label className="text-sm font-medium">
Custom Message (Optional)
</Label>
<p className="text-muted-foreground mb-2 text-xs">
This message will appear between the greeting and invoice summary
</p>
</div>
{/* Editor Toolbar */}
<div className="bg-muted/20 flex flex-wrap items-center gap-1 rounded-lg border p-2">
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive("bold")}
title="Bold"
>
<Bold className="h-4 w-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive("italic")}
title="Italic"
>
<Italic className="h-4 w-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive("strike")}
title="Strikethrough"
>
<Underline className="h-4 w-4" />
</MenuButton>
<Separator orientation="vertical" className="mx-1 h-6" />
<MenuButton
onClick={() => editor.chain().focus().setTextAlign("left").run()}
isActive={editor.isActive({ textAlign: "left" })}
title="Align Left"
>
<AlignLeft className="h-4 w-4" />
</MenuButton>
<MenuButton
onClick={() =>
editor.chain().focus().setTextAlign("center").run()
}
isActive={editor.isActive({ textAlign: "center" })}
title="Align Center"
>
<AlignCenter className="h-4 w-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign("right").run()}
isActive={editor.isActive({ textAlign: "right" })}
title="Align Right"
>
<AlignRight className="h-4 w-4" />
</MenuButton>
<Separator orientation="vertical" className="mx-1 h-6" />
<MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive("bulletList")}
title="Bullet List"
>
<List className="h-4 w-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive("orderedList")}
title="Ordered List"
>
<ListOrdered className="h-4 w-4" />
</MenuButton>
<Separator orientation="vertical" className="mx-1 h-6" />
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
title="Text Color"
>
<Palette className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-2">
<div className="grid grid-cols-6 gap-1">
{colors.map((color) => (
<button
key={color}
type="button"
className="h-6 w-6 rounded border border-gray-300 hover:scale-110"
style={{ backgroundColor: color }}
onClick={() => {
editor.chain().focus().setColor(color).run();
}}
title={color}
/>
))}
</div>
</PopoverContent>
</Popover>
</div>
{/* Rich Text Editor */}
<div>
<EditorContent editor={editor} />
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
interface EmailPreviewProps {
subject: string;
fromEmail: string;
toEmail: string;
ccEmail?: string;
bccEmail?: string;
content: string;
customMessage?: string;
invoice?: {
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
taxRate: number;
status?: string;
totalAmount?: number;
client?: {
name: string;
email: string | null;
};
business?: {
name: string;
email: string | null;
};
items?: Array<{
id: string;
hours: number;
rate: number;
}>;
};
className?: string;
}
export function EmailPreview({
subject,
fromEmail,
toEmail,
ccEmail,
bccEmail,
content,
customMessage,
invoice,
className,
}: EmailPreviewProps) {
// Calculate total from invoice items if available
const calculateTotal = () => {
if (!invoice?.items) return 0;
const subtotal = invoice.items.reduce(
(sum, item) => sum + item.hours * item.rate,
0,
);
const taxAmount = subtotal * (invoice.taxRate / 100);
return subtotal + taxAmount;
};
// Generate the branded email template if invoice is provided
const emailTemplate = invoice
? generateInvoiceEmailTemplate({
invoice: {
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status ?? "draft",
totalAmount: invoice.totalAmount ?? calculateTotal(),
taxRate: invoice.taxRate,
notes: null,
client: {
name: invoice.client?.name ?? "Client",
email: invoice.client?.email ?? null,
},
business: invoice.business ?? null,
items:
invoice.items?.map((item) => ({
date: new Date(),
description: "Service",
hours: item.hours,
rate: item.rate,
amount: item.hours * item.rate,
})) ?? [],
},
customContent: content,
customMessage: customMessage,
userName: invoice.business?.name ?? "Your Business",
userEmail: fromEmail,
baseUrl:
typeof window !== "undefined"
? window.location.origin
: "https://beenvoice.app",
})
: null;
return (
<div className={className}>
{/* Email Headers */}
<div className="bg-muted/20 mb-4 space-y-3 rounded-lg p-4">
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-3">
<div>
<span className="text-muted-foreground block text-xs font-medium">
From:
</span>
<span className="font-mono text-sm break-all">{fromEmail}</span>
</div>
<div>
<span className="text-muted-foreground block text-xs font-medium">
To:
</span>
<span className="font-mono text-sm break-all">{toEmail}</span>
</div>
<div>
<span className="text-muted-foreground block text-xs font-medium">
Subject:
</span>
<span className="text-sm font-semibold break-words">
{subject || "No subject"}
</span>
</div>
</div>
{(ccEmail ?? bccEmail) && (
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-2">
{ccEmail && (
<div>
<span className="text-muted-foreground block text-xs font-medium">
CC:
</span>
<span className="font-mono text-sm break-all">{ccEmail}</span>
</div>
)}
{bccEmail && (
<div>
<span className="text-muted-foreground block text-xs font-medium">
BCC:
</span>
<span className="font-mono text-sm break-all">{bccEmail}</span>
</div>
)}
</div>
)}
</div>
{/* Email Content */}
{emailTemplate ? (
<div className="rounded-lg border bg-gray-50 p-1 shadow-sm">
<iframe
srcDoc={emailTemplate.html}
className="h-[700px] w-full rounded border-0"
title="Email Preview"
sandbox="allow-same-origin"
/>
</div>
) : (
<div className="text-muted-foreground flex min-h-[400px] items-center justify-center">
<p className="text-center text-sm">
Email preview will appear here...
<br />
<span className="text-xs">
Professional beenvoice-branded template will be generated
automatically
</span>
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useRouter } from "next/navigation";
import { Button } from "~/components/ui/button";
import { api } from "~/trpc/react";
import { Send, Loader2, Mail, MailCheck } from "lucide-react";
interface EnhancedSendInvoiceButtonProps {
invoiceId: string;
variant?: "default" | "outline" | "ghost" | "icon";
className?: string;
showResend?: boolean;
size?: "default" | "sm" | "lg" | "icon";
}
export function EnhancedSendInvoiceButton({
invoiceId,
variant = "outline",
className,
showResend = false,
size = "default",
}: EnhancedSendInvoiceButtonProps) {
const router = useRouter();
// Fetch invoice data
const { data: invoiceData, isLoading: invoiceLoading } =
api.invoices.getById.useQuery({
id: invoiceId,
});
// Check if client has email
const hasClientEmail =
invoiceData?.client?.email && invoiceData.client.email.trim() !== "";
const handleSendClick = () => {
router.push(`/dashboard/invoices/${invoiceId}/send`);
};
// Icon variant for compact display
if (variant === "icon") {
return (
<Button
variant="ghost"
size="sm"
className={className}
disabled={invoiceLoading || !hasClientEmail}
onClick={handleSendClick}
title={
!hasClientEmail
? "Client has no email address"
: showResend
? "Resend Invoice"
: "Send Invoice"
}
>
{invoiceLoading ? (
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
) : hasClientEmail ? (
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
) : (
<Mail className="h-3 w-3 opacity-50 sm:h-4 sm:w-4" />
)}
</Button>
);
}
return (
<Button
variant={variant}
size={size}
className={`shadow-sm ${className}`}
disabled={invoiceLoading || !hasClientEmail}
onClick={handleSendClick}
data-testid="enhanced-send-invoice-button"
>
{invoiceLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Loading...</span>
</>
) : !hasClientEmail ? (
<>
<Mail className="mr-2 h-4 w-4 opacity-50" />
<span>No Email Address</span>
</>
) : (
<>
{invoiceData?.status === "sent" ? (
<MailCheck className="mr-2 h-4 w-4" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
<span>{showResend ? "Resend Invoice" : "Send Invoice"}</span>
</>
)}
</Button>
);
}

View File

@@ -2,7 +2,7 @@
import * as React from "react";
import { useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { useDropzone, type FileRejection } from "react-dropzone";
import { cn } from "~/lib/utils";
import { Upload, FileText, X, CheckCircle, AlertCircle } from "lucide-react";
import { Button } from "~/components/ui/button";
@@ -98,7 +98,7 @@ export function FileUpload({
const [errors, setErrors] = React.useState<Record<string, string>>({});
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: any[]) => {
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
// Handle accepted files
const newFiles = [...files, ...acceptedFiles];
setFiles(newFiles);
@@ -106,19 +106,19 @@ export function FileUpload({
// Handle rejected files
const newErrors: Record<string, string> = { ...errors };
rejectedFiles.forEach(({ file, errors }) => {
const errorMessage = errors
.map((e: any) => {
if (e.code === "file-too-large") {
rejectedFiles.forEach(({ file, errors: fileErrors }) => {
const errorMessage = fileErrors
.map((error) => {
if (error.code === "file-too-large") {
return `File is too large. Max size is ${(maxSize / 1024 / 1024).toFixed(1)}MB`;
}
if (e.code === "file-invalid-type") {
if (error.code === "file-invalid-type") {
return "File type not supported";
}
if (e.code === "too-many-files") {
if (error.code === "too-many-files") {
return `Too many files. Max is ${maxFiles}`;
}
return e.message;
return error.message;
})
.join(", ");
newErrors[file.name] = errorMessage;

View File

@@ -38,7 +38,6 @@ const STATUS_OPTIONS = [
{ value: "draft", label: "Draft" },
{ value: "sent", label: "Sent" },
{ value: "paid", label: "Paid" },
{ value: "overdue", label: "Overdue" },
];
interface InvoiceFormProps {
@@ -60,7 +59,7 @@ interface FormData {
clientId: string;
issueDate: Date;
dueDate: Date;
status: "draft" | "sent" | "paid" | "overdue";
status: "draft" | "sent" | "paid";
notes: string;
taxRate: number;
defaultHourlyRate: number;
@@ -158,7 +157,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
clientId: existingInvoice.clientId,
issueDate: new Date(existingInvoice.issueDate),
dueDate: new Date(existingInvoice.dueDate),
status: existingInvoice.status as "draft" | "sent" | "paid" | "overdue",
status: existingInvoice.status as "draft" | "sent" | "paid",
notes: existingInvoice.notes ?? "",
taxRate: existingInvoice.taxRate,
defaultHourlyRate: 25,
@@ -523,9 +522,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Label htmlFor="status">Status</Label>
<Select
value={formData.status}
onValueChange={(
value: "draft" | "sent" | "paid" | "overdue",
) => updateField("status", value)}
onValueChange={(value: "draft" | "sent" | "paid") =>
updateField("status", value)
}
>
<SelectTrigger>
<SelectValue />

View File

@@ -1,23 +1,8 @@
"use client";
import * as React from "react";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { DatePicker } from "~/components/ui/date-picker";
import { NumberInput } from "~/components/ui/number-input";
import {
Trash2,
Plus,
GripVertical,
ChevronUp,
ChevronDown,
} from "lucide-react";
import { cn } from "~/lib/utils";
import {
DndContext,
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
@@ -28,10 +13,24 @@ import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ChevronDown,
ChevronUp,
GripVertical,
Plus,
Trash2,
} from "lucide-react";
import * as React from "react";
import { Button } from "~/components/ui/button";
import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { NumberInput } from "~/components/ui/number-input";
import { cn } from "~/lib/utils";
interface InvoiceItem {
id: string;

View File

@@ -0,0 +1,451 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button";
import { EmailComposer } from "./email-composer";
import { EmailPreview } from "./email-preview";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import {
Send,
Loader2,
Eye,
Edit3,
CheckCircle,
AlertTriangle,
Mail,
} from "lucide-react";
import { Alert, AlertDescription } from "~/components/ui/alert";
interface SendEmailDialogProps {
invoiceId: string;
trigger: React.ReactNode;
invoice?: {
id: string;
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
status: string;
taxRate: number;
client?: {
name: string;
email: string | null;
};
business?: {
name: string;
email: string | null;
};
items?: Array<{
id: string;
hours: number;
rate: number;
}>;
};
onEmailSent?: () => void;
}
export function SendEmailDialog({
invoiceId,
trigger,
invoice,
onEmailSent,
}: SendEmailDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState("compose");
const [isSending, setIsSending] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
// Email content state
const [subject, setSubject] = useState(() =>
invoice
? `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`
: "Invoice from Your Business",
);
const [ccEmail, setCcEmail] = useState("");
const [bccEmail, setBccEmail] = useState("");
const [customMessage, setCustomMessage] = useState("");
const [emailContent, setEmailContent] = useState(() => {
const getTimeOfDayGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
};
if (!invoice) return "";
const businessName = invoice.business?.name ?? "Your Business";
const issueDate = formatDate(invoice.issueDate);
// Calculate total from items
const subtotal =
invoice.items?.reduce((sum, item) => sum + item.hours * item.rate, 0) ??
0;
const taxAmount = subtotal * (invoice.taxRate / 100);
const total = subtotal + taxAmount;
return `<p>${getTimeOfDayGreeting()},</p>
<p>I hope this email finds you well. Please find attached invoice <strong>${invoice.invoiceNumber}</strong> dated ${issueDate}.</p>
<p>The invoice details are as follows:</p>
<ul>
<li><strong>Invoice Number:</strong> ${invoice.invoiceNumber}</li>
<li><strong>Issue Date:</strong> ${issueDate}</li>
<li><strong>Amount Due:</strong> ${new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)}</li>
</ul>
<p>Please let me know if you have any questions or need any clarification regarding this invoice. I appreciate your prompt attention to this matter.</p>
<p>Thank you for your business!</p>
<p>Best regards,<br><strong>${businessName}</strong></p>`;
});
// Get utils for cache invalidation
const utils = api.useUtils();
// Email sending mutation
const sendEmailMutation = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
toast.success("Email sent successfully!", {
description: data.message,
duration: 5000,
});
// Reset state and close dialog
setIsOpen(false);
setActiveTab("compose");
setIsSending(false);
setIsConfirming(false);
// Refresh invoice data
void utils.invoices.getById.invalidate({ id: invoiceId });
// Callback for parent component
onEmailSent?.();
},
onError: (error) => {
console.error("Email send error:", error);
let errorMessage = "Failed to send invoice email";
let errorDescription = error.message;
if (error.message.includes("Invalid recipient")) {
errorMessage = "Invalid Email Address";
errorDescription =
"Please check the client's email address and try again.";
} else if (error.message.includes("domain not verified")) {
errorMessage = "Email Configuration Issue";
errorDescription = "Please contact support to configure email sending.";
} else if (error.message.includes("rate limit")) {
errorMessage = "Too Many Emails";
errorDescription = "Please wait a moment before sending another email.";
} else if (error.message.includes("no email address")) {
errorMessage = "No Email Address";
errorDescription = "This client doesn't have an email address on file.";
}
toast.error(errorMessage, {
description: errorDescription,
duration: 6000,
});
setIsSending(false);
setIsConfirming(false);
},
});
const handleSendEmail = async () => {
if (!invoice?.client?.email || invoice.client.email.trim() === "") {
toast.error("No email address", {
description: "This client doesn't have an email address on file.",
});
return;
}
if (!subject.trim()) {
toast.error("Subject required", {
description: "Please enter an email subject before sending.",
});
return;
}
if (!emailContent.trim()) {
toast.error("Message required", {
description: "Please enter an email message before sending.",
});
return;
}
setIsSending(true);
try {
// Use the enhanced API with custom subject and content
await sendEmailMutation.mutateAsync({
invoiceId,
customSubject: subject,
customContent: emailContent,
customMessage: customMessage.trim() || undefined,
useHtml: true,
ccEmails: ccEmail.trim() || undefined,
bccEmails: bccEmail.trim() || undefined,
});
} catch (error) {
// Error handling is done in the mutation's onError
console.error("Send email error:", error);
}
};
const handleConfirmSend = () => {
setIsConfirming(true);
setActiveTab("confirm");
};
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com";
const toEmail = invoice?.client?.email ?? "";
const canSend =
!isSending &&
subject.trim() &&
emailContent.trim() &&
toEmail &&
toEmail.trim() !== "";
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="h-5 w-5 text-green-600" />
Send Invoice Email
</DialogTitle>
<DialogDescription>
Compose and preview your invoice email before sending to{" "}
{invoice?.client?.name ?? "client"}.
</DialogDescription>
</DialogHeader>
{/* Warning for missing email */}
{(!toEmail || toEmail.trim() === "") && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This client doesn&apos;t have an email address. Please add an
email address to the client before sending the invoice.
</AlertDescription>
</Alert>
)}
{/* Branded Template Info */}
<Alert>
<Mail className="h-4 w-4" />
<AlertDescription>
<strong>Professional Email Template:</strong> Your email will be
sent using a beautifully designed, beenvoice-branded template with
proper fonts and styling. Any custom content you add will be
incorporated into the professional template automatically.
</AlertDescription>
</Alert>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="min-h-0 flex-1"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="compose" className="flex items-center gap-2">
<Edit3 className="h-4 w-4" />
Compose
</TabsTrigger>
<TabsTrigger value="preview" className="flex items-center gap-2">
<Eye className="h-4 w-4" />
Preview
</TabsTrigger>
<TabsTrigger
value="confirm"
className="flex items-center gap-2"
disabled={!isConfirming}
>
<CheckCircle className="h-4 w-4" />
Confirm
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent
value="compose"
className="mt-4 h-full overflow-y-auto"
>
<EmailComposer
subject={subject}
onSubjectChange={setSubject}
content={emailContent}
onContentChange={setEmailContent}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
onCcEmailChange={setCcEmail}
bccEmail={bccEmail}
onBccEmailChange={setBccEmail}
/>
</TabsContent>
<TabsContent
value="preview"
className="mt-4 h-full overflow-y-auto"
>
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
bccEmail={bccEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
className="pr-2"
/>
</TabsContent>
<TabsContent
value="confirm"
className="mt-4 h-full overflow-y-auto"
>
<div className="space-y-6 pr-2">
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
You&apos;re about to send this email to{" "}
<strong>{toEmail}</strong>. The invoice PDF will be
automatically attached.
</AlertDescription>
</Alert>
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
/>
{invoice?.status === "draft" && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This invoice is currently in <strong>draft</strong>{" "}
status. Sending it will automatically change the status to{" "}
<strong>sent</strong>.
</AlertDescription>
</Alert>
)}
</div>
</TabsContent>
</div>
</Tabs>
<DialogFooter className="flex items-center justify-between">
<div className="flex items-center gap-2">
{activeTab === "compose" && (
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
disabled={isSending}
>
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
)}
{activeTab === "preview" && (
<>
<Button
variant="outline"
onClick={() => setActiveTab("compose")}
disabled={isSending}
>
<Edit3 className="mr-2 h-4 w-4" />
Edit
</Button>
<Button
onClick={handleConfirmSend}
disabled={!canSend}
variant="default"
>
<CheckCircle className="mr-2 h-4 w-4" />
Review & Send
</Button>
</>
)}
{activeTab === "confirm" && (
<>
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
disabled={isSending}
>
Back to Preview
</Button>
<Button
onClick={handleSendEmail}
disabled={!canSend || isSending}
className="bg-green-600 hover:bg-green-700"
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Send Email
</>
)}
</Button>
</>
)}
</div>
<Button
variant="ghost"
onClick={() => setIsOpen(false)}
disabled={isSending}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,20 +1,18 @@
"use client";
import { signOut, useSession } from "next-auth/react";
import Link from "next/link";
import { useSession, signOut } from "next-auth/react";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { Logo } from "~/components/branding/logo";
import { SidebarTrigger } from "~/components/navigation/sidebar-trigger";
import { api } from "~/trpc/react";
import { FileText, Edit } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
export function Navbar() {
const { data: session, status } = useSession();
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
// Get current open invoice for quick access
const { data: currentInvoice } = api.invoices.getCurrentOpen.useQuery();
// const { data: currentInvoice } = api.invoices.getCurrentOpen.useQuery();
return (
<header className="fixed top-2 right-2 left-2 z-30 md:top-3 md:right-3 md:left-3">
@@ -30,7 +28,6 @@ export function Navbar() {
</Link>
</div>
<div className="flex items-center gap-2 md:gap-4">
{status === "loading" ? (
<>
<Skeleton className="bg-muted/20 hidden h-5 w-20 sm:inline" />

View File

@@ -22,11 +22,11 @@ export function PageHeader({
switch (variant) {
case "gradient":
return `${baseClasses} text-3xl text-brand-gradient`;
return `${baseClasses} text-3xl text-foreground`;
case "large":
return `${baseClasses} text-4xl text-foreground`;
case "large-gradient":
return `${baseClasses} text-4xl text-brand-gradient`;
return `${baseClasses} text-4xl text-foreground`;
default:
return `${baseClasses} text-3xl text-foreground`;
}

View File

@@ -1,21 +1,21 @@
"use client";
import { format } from "date-fns";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "~/components/ui/breadcrumb";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import React from "react";
import { api } from "~/trpc/react";
import { format } from "date-fns";
import { Skeleton } from "~/components/ui/skeleton";
import { getRouteLabel, capitalize } from "~/lib/pluralize";
import { getRouteLabel } from "~/lib/pluralize";
import { api } from "~/trpc/react";
function isUUID(str: string) {
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
@@ -40,7 +40,7 @@ export function DashboardBreadcrumbs() {
const resourceType = segments[1]; // e.g., 'clients', 'invoices', 'businesses'
const resourceId =
segments[2] && isUUID(segments[2]) ? segments[2] : undefined;
const action = segments[3]; // e.g., 'edit'
// const action = segments[3]; // e.g., 'edit'
// Fetch client data if needed
const { data: client, isLoading: clientLoading } =

View File

@@ -1,12 +1,11 @@
"use client";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { MenuIcon, X } from "lucide-react";
import { useState } from "react";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { navigationConfig } from "~/lib/navigation";
interface SidebarTriggerProps {

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -252,7 +252,7 @@ function CalendarDayButton({
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const _defaultClassNames = getDefaultClassNames();
// const _defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Progress({
className,
@@ -15,17 +15,17 @@ function Progress({
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
);
}
export { Progress }
export { Progress };

View File

@@ -12,10 +12,8 @@ export const env = createEnv({
? z.string()
: z.string().optional(),
DATABASE_URL: z.string().url(),
DATABASE_AUTH_TOKEN:
process.env.NODE_ENV === "production"
? z.string()
: z.string().optional(),
RESEND_API_KEY: z.string().min(1),
RESEND_DOMAIN: z.string().optional(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
@@ -37,7 +35,8 @@ export const env = createEnv({
runtimeEnv: {
AUTH_SECRET: process.env.AUTH_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DATABASE_AUTH_TOKEN: process.env.DATABASE_AUTH_TOKEN,
RESEND_API_KEY: process.env.RESEND_API_KEY,
RESEND_DOMAIN: process.env.RESEND_DOMAIN,
NODE_ENV: process.env.NODE_ENV,
},
/**

View File

@@ -0,0 +1 @@
export { generateInvoiceEmailTemplate } from "./invoice-email";

View File

@@ -0,0 +1,578 @@
interface InvoiceEmailTemplateProps {
invoice: {
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
status: string;
totalAmount: number;
taxRate: number;
notes?: string | null;
client: {
name: string;
email: string | null;
};
business?: {
name: string;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
} | null;
items: Array<{
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}>;
};
customContent?: string;
customMessage?: string;
userName?: string;
userEmail?: string;
baseUrl?: string;
}
export function generateInvoiceEmailTemplate({
invoice,
customContent,
customMessage,
userName,
userEmail,
baseUrl = "https://beenvoice.app",
}: InvoiceEmailTemplateProps): { html: string; text: string } {
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
const getTimeOfDayGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
};
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = subtotal * (invoice.taxRate / 100);
const total = subtotal + taxAmount;
const businessAddress = invoice.business
? [
invoice.business.addressLine1,
invoice.business.addressLine2,
invoice.business.city && invoice.business.state
? `${invoice.business.city}, ${invoice.business.state} ${invoice.business.postalCode ?? ""}`.trim()
: (invoice.business.city ?? invoice.business.state),
invoice.business.country !== "United States"
? invoice.business.country
: null,
]
.filter(Boolean)
.join("<br>")
: "";
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="format-detection" content="telephone=no">
<meta name="format-detection" content="date=no">
<meta name="format-detection" content="address=no">
<meta name="format-detection" content="email=no">
<title>Invoice ${invoice.invoiceNumber}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #1f2937;
background-color: #f9fafb;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
.header {
background: linear-gradient(135deg, #16a34a 0%, #15803d 100%);
padding: 32px 24px;
text-align: center;
color: white;
}
.header-content {
font-size: 28px;
font-weight: bold;
margin-bottom: 8px;
letter-spacing: -0.5px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
.header-subtitle {
font-size: 16px;
opacity: 0.9;
font-weight: normal;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
.content {
padding: 32px 24px;
}
.greeting {
font-size: 16px;
font-weight: bold;
margin-bottom: 24px;
color: #374151;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
.message {
font-size: 15px;
line-height: 1.7;
margin-bottom: 32px;
color: #4b5563;
}
.invoice-card {
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px;
margin: 24px 0;
}
.invoice-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
}
.invoice-number {
font-size: 24px;
font-weight: bold;
color: #16a34a;
margin-bottom: 4px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
.invoice-date {
font-size: 14px;
color: #6b7280;
}
.invoice-amount {
text-align: right;
}
.amount-label {
font-size: 14px;
color: #6b7280;
margin-bottom: 4px;
}
.amount-value {
font-size: 28px;
font-weight: bold;
color: #1f2937;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
.invoice-details {
border-top: 1px solid #e5e7eb;
padding-top: 20px;
margin-top: 20px;
}
.detail-row {
display: table;
width: 100%;
padding: 8px 0;
border-bottom: 1px solid #f3f4f6;
}
.detail-row:last-child {
border-bottom: none;
font-weight: 600;
padding-top: 12px;
border-top: 2px solid #e5e7eb;
margin-top: 8px;
}
.detail-label {
display: table-cell;
font-size: 14px;
color: #6b7280;
text-align: left;
width: 50%;
}
.detail-value {
display: table-cell;
font-size: 14px;
color: #1f2937;
font-weight: bold;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
text-align: right;
width: 50%;
}
.business-info {
background-color: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.business-name {
font-size: 16px;
font-weight: bold;
color: #1f2937;
margin-bottom: 8px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
.business-details {
font-size: 14px;
color: #6b7280;
line-height: 1.5;
}
.custom-content ul {
margin: 16px 0;
padding-left: 24px;
}
.custom-content li {
margin: 8px 0;
padding-left: 4px;
}
.cta-section {
text-align: center;
margin: 32px 0;
padding: 24px;
background-color: #f8fafc;
border-radius: 8px;
}
.cta-text {
font-size: 14px;
color: #6b7280;
margin-bottom: 16px;
}
.attachment-notice {
background-color: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 16px;
margin: 20px 0;
display: flex;
align-items: center;
gap: 12px;
}
.attachment-icon {
width: 20px;
height: 20px;
background-color: #16a34a;
border-radius: 50%;
flex-shrink: 0;
}
.attachment-text {
font-size: 14px;
color: #166534;
font-weight: bold;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
.signature {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #e5e7eb;
}
.signature-name {
font-size: 16px;
font-weight: bold;
color: #1f2937;
margin-bottom: 4px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
.signature-email {
font-size: 14px;
color: #6b7280;
}
.footer {
background-color: #f9fafb;
padding: 24px;
text-align: center;
border-top: 1px solid #e5e7eb;
}
.footer-logo {
max-width: 80px;
height: auto;
margin: 0 auto 8px;
display: block;
}
.footer-text {
font-size: 12px;
color: #9ca3af;
line-height: 1.5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
/* Email client specific fixes */
@media screen and (max-width: 600px) {
.email-container {
width: 100% !important;
max-width: 600px !important;
}
}
/* Outlook specific fixes */
table {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
/* Apple Mail attachment preview fix */
.attachment-notice {
border: 2px dashed #bbf7d0 !important;
background-color: #f0fdf4 !important;
}
@media (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header, .content, .footer {
padding-left: 16px;
padding-right: 16px;
}
.invoice-header {
flex-direction: column;
align-items: flex-start;
}
.invoice-amount {
text-align: left;
}
.detail-row {
display: block;
}
.detail-label,
.detail-value {
display: block;
width: 100%;
text-align: left;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="header-content">Invoice ${invoice.invoiceNumber}</div>
<div class="header-subtitle">From ${invoice.business?.name ?? "Your Business"}</div>
</div>
<div class="content">
<div class="message">
<div class="greeting">${getTimeOfDayGreeting()},</div>
<p>I hope this email finds you well. Please find attached invoice <strong>#${invoice.invoiceNumber}</strong>
for the services provided. The invoice details are summarized below for your reference.</p>
${customMessage ? `<div style="margin: 16px 0; padding: 16px; background-color: #f0fdf4; border-left: 4px solid #16a34a; border-radius: 4px;">${customMessage}</div>` : ""}
</div>
${customContent ? `<div class="message custom-content">${customContent}</div>` : ""}
<div class="invoice-card">
<div class="invoice-header">
<div>
<div class="invoice-number">#${invoice.invoiceNumber}</div>
<div class="invoice-date">Issue Date: ${formatDate(invoice.issueDate)}</div>
<div class="invoice-date">Due Date: ${formatDate(invoice.dueDate)}</div>
</div>
<div class="invoice-amount">
<div class="amount-label">Total Amount</div>
<div class="amount-value">${formatCurrency(total)}</div>
</div>
</div>
<div class="invoice-details">
<div class="detail-row">
<span class="detail-label">Client</span>
<span class="detail-value">${invoice.client.name}</span>
</div>
<div class="detail-row">
<span class="detail-label">Subtotal</span>
<span class="detail-value">${formatCurrency(subtotal)}</span>
</div>
${
invoice.taxRate > 0
? `<div class="detail-row">
<span class="detail-label">Tax (${invoice.taxRate}%)</span>
<span class="detail-value">${formatCurrency(taxAmount)}</span>
</div>`
: ""
}
<div class="detail-row">
<span class="detail-label">Total</span>
<span class="detail-value">${formatCurrency(total)}</span>
</div>
</div>
</div>
<div class="attachment-notice">
<div class="attachment-icon"></div>
<div class="attachment-text">
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
</div>
</div>
<div class="cta-section">
<div class="cta-text">
If you have any questions about this invoice, please don't hesitate to reach out.
Thank you for your business!
</div>
</div>
${
!customContent
? `<div class="signature">
<div class="signature-name">${userName ?? invoice.business?.name ?? "Best regards"}</div>
${userEmail ? `<div class="signature-email">${userEmail}</div>` : ""}
</div>`
: ""
}
</div>
<div class="footer">
<img src="${baseUrl}/beenvoice-logo.svg" alt="beenvoice" class="footer-logo" />
${
invoice.business
? `<div class="footer-text">
<strong>${invoice.business.name}</strong><br>
${invoice.business.email ? `${invoice.business.email}<br>` : ""}
${invoice.business.phone ? `${invoice.business.phone}<br>` : ""}
${businessAddress ? `${businessAddress}` : ""}
</div>`
: `<div class="footer-text">
Professional invoicing for modern businesses
</div>`
}
</div>
</div>
</body>
</html>`;
// Generate plain text version
const text = `
${getTimeOfDayGreeting()},
I hope this email finds you well. Please find attached invoice #${invoice.invoiceNumber} for the services provided.${
customMessage
? `\n\n${customMessage
.replace(/<[^>]*>/g, "")
.replace(/\s+/g, " ")
.trim()}`
: ""
}${
customContent
? `\n\n${customContent
.replace(/<[^>]*>/g, "")
.replace(/\s+/g, " ")
.trim()}`
: ""
}
INVOICE DETAILS
═══════════════
Invoice Number: #${invoice.invoiceNumber}
Issue Date: ${formatDate(invoice.issueDate)}
Due Date: ${formatDate(invoice.dueDate)}
Client: ${invoice.client.name}
AMOUNT BREAKDOWN
═══════════════
Subtotal: ${formatCurrency(subtotal)}${
invoice.taxRate > 0
? `\nTax (${invoice.taxRate}%): ${formatCurrency(taxAmount)}`
: ""
}
Total: ${formatCurrency(total)}
ATTACHMENT
═══════════════
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
If you have any questions about this invoice, please don't hesitate to reach out.
Thank you for your business!
${userName ?? invoice.business?.name ?? "Best regards"}${
userEmail ? `\n${userEmail}` : ""
}
---
${
invoice.business
? `${invoice.business.name}${invoice.business.email ? `\n${invoice.business.email}` : ""}${
invoice.business.phone ? `\n${invoice.business.phone}` : ""
}${businessAddress ? `\n${businessAddress.replace(/<br>/g, "\n")}` : ""}`
: "Professional invoicing for modern businesses"
}
`.trim();
return { html, text };
}

84
src/lib/email-utils.ts Normal file
View File

@@ -0,0 +1,84 @@
import { generateInvoiceEmailTemplate } from "./email-templates";
// Simple test utility to verify the email template works
export function testEmailTemplate() {
const mockInvoice = {
invoiceNumber: "INV-001",
issueDate: new Date(),
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
status: "draft",
totalAmount: 1000,
taxRate: 8.5,
notes: null,
client: {
name: "Test Client",
email: "client@example.com",
},
business: {
name: "Test Business",
email: "business@example.com",
phone: "(555) 123-4567",
addressLine1: "123 Business St",
addressLine2: null,
city: "Business City",
state: "CA",
postalCode: "12345",
country: "United States",
},
items: [
{
date: new Date(),
description: "Development Services",
hours: 10,
rate: 100,
amount: 1000,
},
],
};
try {
const template = generateInvoiceEmailTemplate({
invoice: mockInvoice,
userName: "John Doe",
userEmail: "john@example.com",
});
return {
success: true,
hasHtml: !!template.html,
hasText: !!template.text,
htmlLength: template.html.length,
textLength: template.text.length,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
// Format currency for display
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}
// Format date for email display
export function formatEmailDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
}
// Get time-based greeting
export function getGreeting(): string {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
}

View File

@@ -305,7 +305,7 @@ export function formatWebsiteUrl(url: string): string {
if (!url) return "";
// If URL doesn't start with http:// or https://, add https://
if (!url.match(/^https?:\/\//i)) {
if (!/^https?:\/\//i.exec(url)) {
return `https://${url}`;
}
@@ -315,7 +315,7 @@ export function formatWebsiteUrl(url: string): string {
// Postal code formatting
export function formatPostalCode(
value: string,
country: string = "United States",
country = "United States",
): string {
if (country === "United States") {
// Format as US ZIP code (12345 or 12345-6789)
@@ -340,7 +340,7 @@ export function formatPostalCode(
}
// Tax ID formatting
export function formatTaxId(value: string, type: string = "EIN"): string {
export function formatTaxId(value: string, type = "EIN"): string {
const digits = value.replace(/\D/g, "");
if (type === "EIN") {

137
src/lib/invoice-status.ts Normal file
View File

@@ -0,0 +1,137 @@
import type {
StoredInvoiceStatus,
EffectiveInvoiceStatus,
} from "~/types/invoice";
// Types are now imported from ~/types/invoice
/**
* Calculate the effective status of an invoice including overdue computation
*/
export function getEffectiveInvoiceStatus(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
): EffectiveInvoiceStatus {
// If already paid, status is always paid regardless of due date
if (storedStatus === "paid") {
return "paid";
}
// If draft, status is always draft
if (storedStatus === "draft") {
return "draft";
}
// For sent invoices, check if overdue
if (storedStatus === "sent") {
const today = new Date();
const due = new Date(dueDate);
// Set both dates to start of day for accurate comparison
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
return due < today ? "overdue" : "sent";
}
return storedStatus;
}
/**
* Check if an invoice is overdue
*/
export function isInvoiceOverdue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
): boolean {
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue";
}
/**
* Get days past due (returns 0 if not overdue)
*/
export function getDaysPastDue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
): number {
if (!isInvoiceOverdue(storedStatus, dueDate)) {
return 0;
}
const today = new Date();
const due = new Date(dueDate);
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
const diffTime = today.getTime() - due.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return Math.max(0, diffDays);
}
/**
* Status configuration for UI display
*/
export const statusConfig = {
draft: {
label: "Draft",
color: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300",
description: "Invoice is being prepared",
},
sent: {
label: "Sent",
color: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
description: "Invoice sent to client",
},
paid: {
label: "Paid",
color: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
description: "Payment received",
},
overdue: {
label: "Overdue",
color: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
description: "Payment is overdue",
},
} as const;
/**
* Get status configuration for display
*/
export function getStatusConfig(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
) {
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, dueDate);
return statusConfig[effectiveStatus];
}
/**
* Get valid status transitions from current stored status
*/
export function getValidStatusTransitions(
currentStatus: StoredInvoiceStatus,
): StoredInvoiceStatus[] {
switch (currentStatus) {
case "draft":
return ["sent", "paid"]; // Can send or mark paid directly
case "sent":
return ["paid", "draft"]; // Can mark paid or revert to draft
case "paid":
return ["sent"]; // Can revert to sent if needed (rare cases)
default:
return [];
}
}
/**
* Check if a status transition is valid
*/
export function isValidStatusTransition(
from: StoredInvoiceStatus,
to: StoredInvoiceStatus,
): boolean {
const validTransitions = getValidStatusTransitions(from);
return validTransitions.includes(to);
}

View File

@@ -11,43 +11,68 @@ import {
import { saveAs } from "file-saver";
import React from "react";
// Register Inter font
Font.register({
family: "Inter",
src: "/fonts/inter/Inter-Variable.ttf",
fontWeight: "normal",
});
// Global font registration state
let fontsRegistered = false;
Font.register({
family: "Inter",
src: "/fonts/inter/Inter-Italic-Variable.ttf",
fontStyle: "italic",
});
// Font registration helper that works in both client and server environments
const registerFonts = () => {
try {
// Avoid duplicate registration
if (fontsRegistered) {
return;
}
// Register Azeret Mono fonts for numbers and tables - multiple weights
Font.register({
family: "AzeretMono",
src: "/fonts/azeret/AzeretMono-Regular.ttf",
fontWeight: "normal",
});
// Only register custom fonts on client side for now
// Server-side will use fallback fonts to avoid path/loading issues
if (typeof window !== "undefined") {
// Register Inter font
Font.register({
family: "Inter",
src: "/fonts/inter/Inter-Variable.ttf",
fontWeight: "normal",
});
Font.register({
family: "AzeretMono",
src: "/fonts/azeret/AzeretMono-Regular.ttf",
fontWeight: "semibold",
});
Font.register({
family: "Inter",
src: "/fonts/inter/Inter-Italic-Variable.ttf",
fontStyle: "italic",
});
Font.register({
family: "AzeretMono",
src: "/fonts/azeret/AzeretMono-Regular.ttf",
fontWeight: "bold",
});
// Register Azeret Mono fonts for numbers and tables - multiple weights
Font.register({
family: "AzeretMono",
src: "/fonts/azeret/AzeretMono-Regular.ttf",
fontWeight: "normal",
});
Font.register({
family: "AzeretMono",
src: "/fonts/azeret/AzeretMono-Italic-Variable.ttf",
fontStyle: "italic",
});
Font.register({
family: "AzeretMono",
src: "/fonts/azeret/AzeretMono-Regular.ttf",
fontWeight: "semibold",
});
Font.register({
family: "AzeretMono",
src: "/fonts/azeret/AzeretMono-Regular.ttf",
fontWeight: "bold",
});
Font.register({
family: "AzeretMono",
src: "/fonts/azeret/AzeretMono-Italic-Variable.ttf",
fontStyle: "italic",
});
}
fontsRegistered = true;
} catch (error) {
console.warn("Font registration failed, using built-in fonts:", error);
fontsRegistered = true; // Don't keep trying
}
};
// Register fonts immediately
registerFonts();
interface InvoiceData {
invoiceNumber: string;
@@ -94,7 +119,7 @@ const styles = StyleSheet.create({
page: {
flexDirection: "column",
backgroundColor: "#ffffff",
fontFamily: "Inter",
fontFamily: "Helvetica",
fontSize: 10,
paddingTop: 40,
paddingBottom: 80,
@@ -121,16 +146,18 @@ const styles = StyleSheet.create({
},
businessName: {
fontSize: 24,
fontWeight: "bold",
fontFamily: "Helvetica-Bold",
fontSize: 18,
color: "#111827",
marginBottom: 4,
},
businessInfo: {
fontSize: 11,
fontSize: 10,
fontFamily: "Helvetica",
color: "#6b7280",
marginBottom: 2,
lineHeight: 1.3,
marginBottom: 3,
},
businessAddress: {
@@ -147,15 +174,14 @@ const styles = StyleSheet.create({
invoiceTitle: {
fontSize: 32,
fontWeight: "bold",
fontFamily: "Helvetica-Bold",
color: "#10b981",
marginBottom: 8,
},
invoiceNumber: {
fontSize: 15,
fontWeight: "semibold",
fontFamily: "AzeretMono",
fontFamily: "Courier-Bold",
color: "#111827",
marginBottom: 4,
},
@@ -165,7 +191,7 @@ const styles = StyleSheet.create({
paddingVertical: 4,
borderRadius: 4,
fontSize: 12,
fontWeight: "bold",
fontFamily: "Helvetica-Bold",
textAlign: "center",
},
@@ -193,21 +219,23 @@ const styles = StyleSheet.create({
sectionTitle: {
fontSize: 14,
fontWeight: "bold",
fontFamily: "Helvetica-Bold",
color: "#111827",
marginBottom: 12,
},
clientName: {
fontSize: 13,
fontWeight: "bold",
fontFamily: "Helvetica-Bold",
fontSize: 14,
color: "#111827",
marginBottom: 4,
marginBottom: 2,
},
clientInfo: {
fontSize: 11,
fontSize: 10,
fontFamily: "Helvetica",
color: "#6b7280",
lineHeight: 1.3,
marginBottom: 2,
},
@@ -232,9 +260,8 @@ const styles = StyleSheet.create({
detailValue: {
fontSize: 10,
fontFamily: "AzeretMono",
fontFamily: "Courier-Bold",
color: "#111827",
fontWeight: "semibold",
flex: 1,
textAlign: "right",
},
@@ -251,17 +278,25 @@ const styles = StyleSheet.create({
notesTitle: {
fontSize: 12,
fontWeight: "bold",
fontFamily: "Helvetica-Bold",
color: "#111827",
marginBottom: 6,
},
notesContent: {
fontSize: 10,
fontFamily: "Helvetica",
color: "#6b7280",
lineHeight: 1.4,
},
businessContact: {
fontSize: 9,
fontFamily: "Helvetica",
color: "#6b7280",
lineHeight: 1.2,
},
// Separator styles
headerSeparator: {
height: 1,
@@ -280,8 +315,8 @@ const styles = StyleSheet.create({
},
abridgedBusinessName: {
fontSize: 18,
fontWeight: "bold",
fontSize: 12,
fontFamily: "Helvetica-Bold",
color: "#111827",
},
@@ -293,14 +328,13 @@ const styles = StyleSheet.create({
abridgedInvoiceTitle: {
fontSize: 16,
fontWeight: "bold",
fontFamily: "Helvetica-Bold",
color: "#10b981",
},
abridgedInvoiceNumber: {
fontSize: 13,
fontWeight: "semibold",
fontFamily: "AzeretMono",
fontFamily: "Courier-Bold",
color: "#111827",
},
@@ -320,7 +354,7 @@ const styles = StyleSheet.create({
tableHeaderCell: {
fontSize: 11,
fontWeight: "bold",
fontFamily: "Helvetica-Bold",
color: "#111827",
paddingHorizontal: 4,
},
@@ -369,8 +403,7 @@ const styles = StyleSheet.create({
tableCellDate: {
width: "15%",
fontFamily: "AzeretMono",
fontWeight: "semibold",
fontFamily: "Courier",
alignSelf: "flex-start",
},
@@ -383,24 +416,21 @@ const styles = StyleSheet.create({
tableCellHours: {
width: "12%",
textAlign: "right",
fontFamily: "AzeretMono",
fontWeight: "semibold",
fontFamily: "Courier",
alignSelf: "flex-start",
},
tableCellRate: {
width: "15%",
textAlign: "right",
fontFamily: "AzeretMono",
fontWeight: "semibold",
fontFamily: "Courier",
alignSelf: "flex-start",
},
tableCellAmount: {
width: "18%",
textAlign: "right",
fontFamily: "AzeretMono",
fontWeight: "bold",
fontFamily: "Courier-Bold",
alignSelf: "flex-start",
},
@@ -432,9 +462,8 @@ const styles = StyleSheet.create({
totalAmount: {
fontSize: 10,
fontFamily: "AzeretMono",
fontFamily: "Courier-Bold",
color: "#111827",
fontWeight: "semibold",
},
finalTotalRow: {
@@ -447,19 +476,19 @@ const styles = StyleSheet.create({
finalTotalLabel: {
fontSize: 14,
fontWeight: "bold",
fontFamily: "Helvetica-Bold",
color: "#1f2937",
},
finalTotalAmount: {
fontSize: 15,
fontFamily: "AzeretMono",
fontWeight: "bold",
fontFamily: "Courier-Bold",
color: "#10b981",
},
itemCount: {
fontSize: 9,
fontFamily: "Helvetica",
color: "#6b7280",
textAlign: "center",
marginTop: 6,
@@ -757,6 +786,7 @@ const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
const Footer: React.FC = () => (
<View style={styles.footer} fixed>
<View style={styles.footerLogo}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<Image
src="/beenvoice-logo.png"
style={{
@@ -892,6 +922,9 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
// Export functions
export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
try {
// Ensure fonts are registered
registerFonts();
// Validate invoice data
if (!invoice) {
throw new Error("Invoice data is required");
@@ -935,6 +968,11 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
throw new Error("Generated PDF is invalid. Please try again.");
} else if (error.message.includes("required")) {
throw new Error(error.message);
} else if (
error.message.includes("font") ||
error.message.includes("Font")
) {
throw new Error("Font loading error. Please try again.");
}
}
@@ -946,7 +984,12 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
export async function generateInvoicePDFBlob(
invoice: InvoiceData,
): Promise<Blob> {
const isServerSide = typeof window === "undefined";
try {
// Ensure fonts are registered (important for server-side generation)
registerFonts();
// Validate invoice data
if (!invoice) {
throw new Error("Invoice data is required");
@@ -960,17 +1003,56 @@ export async function generateInvoicePDFBlob(
throw new Error("Client information is required");
}
// Generate PDF blob
const blob = await pdf(<InvoicePDF invoice={invoice} />).toBlob();
console.log(
`Generating PDF blob for invoice ${invoice.invoiceNumber} (${isServerSide ? "server-side" : "client-side"})...`,
);
// Generate PDF blob with timeout (same as generateInvoicePDF)
const pdfPromise = pdf(<InvoicePDF invoice={invoice} />).toBlob();
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("PDF generation timed out")), 30000),
);
const blob = await Promise.race([pdfPromise, timeoutPromise]);
// Validate blob
if (!blob || blob.size === 0) {
throw new Error("Generated PDF is empty");
}
console.log(
`PDF blob generated successfully, size: ${blob.size} bytes (${isServerSide ? "server-side" : "client-side"})`,
);
return blob;
} catch (error) {
console.error("PDF generation error:", error);
throw new Error("Failed to generate PDF");
console.error(
`PDF generation error for email attachment (${isServerSide ? "server-side" : "client-side"}):`,
error,
);
// Provide more specific error messages (same as generateInvoicePDF)
if (error instanceof Error) {
if (error.message.includes("timeout")) {
throw new Error("PDF generation took too long. Please try again.");
} else if (error.message.includes("empty")) {
throw new Error("Generated PDF is invalid. Please try again.");
} else if (error.message.includes("required")) {
throw new Error(error.message);
} else if (
error.message.includes("font") ||
error.message.includes("Font")
) {
throw new Error("Font loading error. Please try again.");
} else if (
error.message.includes("Cannot resolve") ||
error.message.includes("Failed to load")
) {
throw new Error("Resource loading error during PDF generation.");
}
}
throw new Error(
`Failed to generate PDF for email attachment: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}

View File

@@ -1,7 +1,10 @@
/**
* Pluralization rules for common entities in the app
*/
const PLURALIZATION_RULES: Record<string, { singular: string; plural: string }> = {
const PLURALIZATION_RULES: Record<
string,
{ singular: string; plural: string }
> = {
business: { singular: "Business", plural: "Businesses" },
client: { singular: "Client", plural: "Clients" },
invoice: { singular: "Invoice", plural: "Invoices" },
@@ -58,7 +61,7 @@ export function singularize(word: string): string {
// Check if we have a specific rule for this word (search by plural)
const rule = Object.values(PLURALIZATION_RULES).find(
(r) => r.plural.toLowerCase() === lowerWord
(r) => r.plural.toLowerCase() === lowerWord,
);
if (rule) {
@@ -101,7 +104,7 @@ export function capitalize(word: string): string {
/**
* Get a properly formatted label for a route segment
*/
export function getRouteLabel(segment: string, isPlural: boolean = true): string {
export function getRouteLabel(segment: string, isPlural = true): string {
// First, check if it's already in our rules
const rule = PLURALIZATION_RULES[segment.toLowerCase()];
if (rule) {

View File

@@ -2,6 +2,7 @@ import { clientsRouter } from "~/server/api/routers/clients";
import { businessesRouter } from "~/server/api/routers/businesses";
import { invoicesRouter } from "~/server/api/routers/invoices";
import { settingsRouter } from "~/server/api/routers/settings";
import { emailRouter } from "~/server/api/routers/email";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
/**
@@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({
businesses: businessesRouter,
invoices: invoicesRouter,
settings: settingsRouter,
email: emailRouter,
});
// export type definition of API

View File

@@ -21,6 +21,20 @@ const businessSchema = z.object({
isDefault: z.boolean().default(false),
});
const emailConfigSchema = z.object({
resendApiKey: z
.string()
.min(1, "Resend API Key is required")
.optional()
.or(z.literal("")),
resendDomain: z
.string()
.min(1, "Resend Domain is required")
.optional()
.or(z.literal("")),
emailFromName: z.string().optional().or(z.literal("")),
});
export const businessesRouter = createTRPCRouter({
// Get all businesses for the current user
getAll: protectedProcedure.query(async ({ ctx }) => {
@@ -208,4 +222,93 @@ export const businessesRouter = createTRPCRouter({
return updatedBusiness;
}),
// Update email configuration for a business
updateEmailConfig: protectedProcedure
.input(
z.object({
id: z.string(),
...emailConfigSchema.shape,
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...emailConfig } = input;
// Validate that business belongs to user
const business = await ctx.db
.select()
.from(businesses)
.where(
and(
eq(businesses.id, id),
eq(businesses.createdById, ctx.session.user.id),
),
)
.limit(1);
if (!business[0]) {
throw new Error(
"Business not found or you don't have permission to update it",
);
}
// Update email configuration
const [updatedBusiness] = await ctx.db
.update(businesses)
.set({
resendApiKey: emailConfig.resendApiKey ?? null,
resendDomain: emailConfig.resendDomain ?? null,
emailFromName: emailConfig.emailFromName ?? null,
updatedAt: new Date(),
})
.where(
and(
eq(businesses.id, id),
eq(businesses.createdById, ctx.session.user.id),
),
)
.returning();
if (!updatedBusiness) {
throw new Error("Failed to update email configuration");
}
return {
success: true,
message: "Email configuration updated successfully",
};
}),
// Get email configuration for a business (without exposing the API key)
getEmailConfig: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const business = await ctx.db
.select({
id: businesses.id,
name: businesses.name,
resendDomain: businesses.resendDomain,
emailFromName: businesses.emailFromName,
hasApiKey: businesses.resendApiKey,
})
.from(businesses)
.where(
and(
eq(businesses.id, input.id),
eq(businesses.createdById, ctx.session.user.id),
),
)
.limit(1);
if (!business[0]) {
throw new Error(
"Business not found or you don't have permission to view it",
);
}
return {
...business[0],
hasApiKey: !!business[0].hasApiKey,
};
}),
});

View File

@@ -0,0 +1,311 @@
import { z } from "zod";
import { Resend } from "resend";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices } from "~/server/db/schema";
import { eq } from "drizzle-orm";
import { env } from "~/env";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
// Default Resend instance - will be overridden if business has custom API key
const defaultResend = new Resend(env.RESEND_API_KEY);
export const emailRouter = createTRPCRouter({
sendInvoice: protectedProcedure
.input(
z.object({
invoiceId: z.string(),
customSubject: z.string().optional(),
customContent: z.string().optional(),
customMessage: z.string().optional(),
useHtml: z.boolean().default(false),
ccEmails: z.string().optional(),
bccEmails: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
// Fetch invoice with relations
const invoice = await ctx.db.query.invoices.findFirst({
where: eq(invoices.id, input.invoiceId),
with: {
client: true,
business: true,
items: true,
},
});
if (!invoice) {
throw new Error("Invoice not found");
}
// Check if invoice belongs to the current user
if (invoice.createdById !== ctx.session.user.id) {
throw new Error("Unauthorized");
}
if (!invoice.client?.email) {
throw new Error("Client has no email address");
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(invoice.client.email)) {
throw new Error("Invalid client email address format");
}
// Generate PDF for attachment
let pdfBuffer: Buffer;
try {
const pdfBlob = await generateInvoicePDFBlob(invoice);
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
// Validate PDF was generated successfully
if (pdfBuffer.length === 0) {
throw new Error("Generated PDF is empty");
}
} catch (pdfError) {
console.error("PDF generation error:", pdfError);
// Re-throw the original error with more context
if (pdfError instanceof Error) {
throw new Error(
`Failed to generate invoice PDF for attachment: ${pdfError.message}`,
);
}
throw new Error("Failed to generate invoice PDF for attachment");
}
// Create email content
const subject =
input.customSubject ??
`Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`;
const userName =
invoice.business?.emailFromName ??
invoice.business?.name ??
ctx.session.user?.name ??
"Your Name";
const userEmail =
invoice.business?.email ?? ctx.session.user?.email ?? "";
// Generate branded email template
const emailTemplate = generateInvoiceEmailTemplate({
invoice: {
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status,
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
notes: invoice.notes,
client: {
name: invoice.client.name,
email: invoice.client.email,
},
business: invoice.business,
items: invoice.items,
},
customContent: input.customContent,
customMessage: input.customMessage,
userName,
userEmail,
baseUrl: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: process.env.NODE_ENV === "production"
? "https://beenvoice.app"
: "http://localhost:3000",
});
// Determine Resend instance and email configuration to use
let resendInstance: Resend;
let fromEmail: string;
console.log("Email configuration debug:");
console.log(
"- Business resendApiKey:",
invoice.business?.resendApiKey ? "***SET***" : "not set",
);
console.log("- Business resendDomain:", invoice.business?.resendDomain);
console.log("- System RESEND_DOMAIN:", env.RESEND_DOMAIN);
console.log("- Business email:", invoice.business?.email);
// Check if business has custom Resend configuration
if (invoice.business?.resendApiKey && invoice.business?.resendDomain) {
// Use business's custom Resend setup
resendInstance = new Resend(invoice.business.resendApiKey);
const fromName =
invoice.business.emailFromName ?? invoice.business.name ?? userName;
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
console.log("- Using business custom Resend configuration");
} else if (env.RESEND_DOMAIN) {
// Use system Resend configuration
resendInstance = defaultResend;
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
console.log("- Using system Resend configuration");
} else {
// Fallback to business email if no configured domains
resendInstance = defaultResend;
fromEmail = invoice.business?.email ?? "noreply@yourdomain.com";
console.log("- Using fallback configuration");
}
console.log("- Final fromEmail:", fromEmail);
// Prepare CC and BCC lists
const ccEmails: string[] = [];
const bccEmails: string[] = [];
// Parse CC emails from input
if (input.ccEmails) {
const ccList = input.ccEmails
.split(",")
.map((email) => email.trim())
.filter((email) => email);
for (const email of ccList) {
if (emailRegex.test(email)) {
ccEmails.push(email);
} else {
console.warn("Invalid CC email format, skipping:", email);
}
}
}
// Parse BCC emails from input
if (input.bccEmails) {
const bccList = input.bccEmails
.split(",")
.map((email) => email.trim())
.filter((email) => email);
for (const email of bccList) {
if (emailRegex.test(email)) {
bccEmails.push(email);
} else {
console.warn("Invalid BCC email format, skipping:", email);
}
}
}
// Include business email in CC if it exists and is different from sender
if (invoice.business?.email && invoice.business.email !== fromEmail) {
// Validate business email format before adding to CC
if (emailRegex.test(invoice.business.email)) {
ccEmails.push(invoice.business.email);
} else {
console.warn(
"Invalid business email format, skipping CC:",
invoice.business.email,
);
}
}
// Send email with Resend
let emailResult;
try {
// Send HTML email with plain text fallback
emailResult = await resendInstance.emails.send({
from: fromEmail,
to: [invoice.client?.email ?? ""],
cc: ccEmails.length > 0 ? ccEmails : undefined,
bcc: bccEmails.length > 0 ? bccEmails : undefined,
subject: subject,
html: emailTemplate.html,
text: emailTemplate.text,
headers: {
"X-Priority": "3",
"X-MSMail-Priority": "Normal",
"X-Mailer": "beenvoice",
"MIME-Version": "1.0",
},
attachments: [
{
filename: `invoice-${invoice.invoiceNumber}.pdf`,
content: pdfBuffer,
},
],
});
} catch (sendError) {
console.error("Resend API call failed:", sendError);
throw new Error(
"Email service is currently unavailable. Please try again later.",
);
}
// Enhanced error checking
if (emailResult.error) {
console.error("Resend API error:", emailResult.error);
const errorMsg = emailResult.error.message?.toLowerCase() ?? "";
// Provide more specific error messages based on error type
if (
errorMsg.includes("invalid email") ||
errorMsg.includes("invalid recipient")
) {
throw new Error("Invalid recipient email address");
} else if (
errorMsg.includes("domain") ||
errorMsg.includes("not verified")
) {
throw new Error(
"Email domain not verified. Please configure your Resend domain in business settings.",
);
} else if (
errorMsg.includes("rate limit") ||
errorMsg.includes("too many")
) {
throw new Error("Rate limit exceeded. Please try again later.");
} else if (
errorMsg.includes("api key") ||
errorMsg.includes("unauthorized")
) {
throw new Error(
"Email service configuration error. Please check your Resend API key.",
);
} else if (
errorMsg.includes("attachment") ||
errorMsg.includes("file size")
) {
throw new Error("Invoice PDF is too large to send via email.");
} else {
throw new Error(
`Email delivery failed: ${emailResult.error.message ?? "Unknown error"}`,
);
}
}
if (!emailResult.data?.id) {
throw new Error(
"Email was not sent successfully - no delivery ID received",
);
}
// Update invoice status to "sent" if it was draft
if (invoice.status === "draft") {
try {
await ctx.db
.update(invoices)
.set({
status: "sent",
updatedAt: new Date(),
})
.where(eq(invoices.id, input.invoiceId));
} catch (dbError) {
console.error("Failed to update invoice status:", dbError);
// Don't throw here - email was sent successfully, status update is secondary
console.warn(
`Invoice ${invoice.invoiceNumber} sent but status not updated`,
);
}
}
return {
success: true,
emailId: emailResult.data.id,
message: `Invoice sent successfully to ${invoice.client?.email ?? "client"}${ccEmails.length > 0 ? ` (CC: ${ccEmails.join(", ")})` : ""}${bccEmails.length > 0 ? ` (BCC: ${bccEmails.join(", ")})` : ""}`,
deliveryDetails: {
to: invoice.client?.email ?? "",
cc: ccEmails,
bcc: bccEmails,
sentAt: new Date().toISOString(),
},
};
}),
});

View File

@@ -26,7 +26,7 @@ const createInvoiceSchema = z.object({
clientId: z.string().min(1, "Client is required"),
issueDate: z.date(),
dueDate: z.date(),
status: z.enum(["draft", "sent", "paid", "overdue"]).default("draft"),
status: z.enum(["draft", "sent", "paid"]).default("draft"),
notes: z.string().optional().or(z.literal("")),
taxRate: z.number().min(0).max(100).default(0),
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
@@ -38,7 +38,7 @@ const updateInvoiceSchema = createInvoiceSchema.partial().extend({
const updateStatusSchema = z.object({
id: z.string(),
status: z.enum(["draft", "sent", "paid", "overdue"]),
status: z.enum(["draft", "sent", "paid"]),
});
export const invoicesRouter = createTRPCRouter({
@@ -237,7 +237,9 @@ export const invoicesRouter = createTRPCRouter({
const cleanInvoiceData = {
...invoiceData,
businessId:
!invoiceData.businessId || invoiceData.businessId.trim() === "" ? null : invoiceData.businessId,
!invoiceData.businessId || invoiceData.businessId.trim() === ""
? null
: invoiceData.businessId,
notes: invoiceData.notes === "" ? null : invoiceData.notes,
};
@@ -261,7 +263,10 @@ export const invoicesRouter = createTRPCRouter({
}
// If business is being updated, verify it belongs to user
if (cleanInvoiceData.businessId && cleanInvoiceData.businessId.trim() !== "") {
if (
cleanInvoiceData.businessId &&
cleanInvoiceData.businessId.trim() !== ""
) {
const business = await ctx.db.query.businesses.findFirst({
where: eq(businesses.id, cleanInvoiceData.businessId),
});
@@ -434,7 +439,10 @@ export const invoicesRouter = createTRPCRouter({
console.log("Status update completed successfully");
return { success: true };
return {
success: true,
message: `Invoice status updated to ${input.status}`,
};
} catch (error) {
console.error("UpdateStatus error:", error);
if (error instanceof TRPCError) throw error;

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { eq, and } from "drizzle-orm";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import {
@@ -40,7 +40,7 @@ const BusinessBackupSchema = z.object({
});
const InvoiceItemBackupSchema = z.object({
date: z.date(),
date: z.string().transform((str) => new Date(str)),
description: z.string(),
hours: z.number(),
rate: z.number(),
@@ -52,8 +52,8 @@ const InvoiceBackupSchema = z.object({
invoiceNumber: z.string(),
businessName: z.string().optional(),
clientName: z.string(),
issueDate: z.date(),
dueDate: z.date(),
issueDate: z.string().transform((str) => new Date(str)),
dueDate: z.string().transform((str) => new Date(str)),
status: z.string().default("draft"),
totalAmount: z.number().default(0),
taxRate: z.number().default(0),
@@ -137,7 +137,7 @@ export const settingsRouter = createTRPCRouter({
},
});
if (!user || !user.password) {
if (!user?.password) {
throw new Error("User not found or no password set");
}

View File

@@ -47,13 +47,16 @@ export const authConfig = {
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
if (typeof credentials.email !== 'string' || typeof credentials.password !== 'string') {
if (
typeof credentials.email !== "string" ||
typeof credentials.password !== "string"
) {
return null;
}
@@ -61,11 +64,14 @@ export const authConfig = {
where: eq(users.email, credentials.email),
});
if (!user || !user.password) {
if (!user?.password) {
return null;
}
const isPasswordValid = await bcrypt.compare(credentials.password, user.password);
const isPasswordValid = await bcrypt.compare(
credentials.password,
user.password,
);
if (!isPasswordValid) {
return null;
@@ -76,7 +82,7 @@ export const authConfig = {
email: user.email,
name: user.name,
};
}
},
}),
],
adapter: DrizzleAdapter(db, {

View File

@@ -1,5 +1,5 @@
import { createClient, type Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
import { env } from "~/env";
import * as schema from "./schema";
@@ -9,15 +9,18 @@ import * as schema from "./schema";
* update.
*/
const globalForDb = globalThis as unknown as {
client: Client | undefined;
pool: Pool | undefined;
};
export const client =
globalForDb.client ??
createClient({
url: env.DATABASE_URL,
authToken: env.DATABASE_AUTH_TOKEN,
export const pool =
globalForDb.pool ??
new Pool({
connectionString: env.DATABASE_URL,
ssl: env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
if (env.NODE_ENV !== "production") globalForDb.client = client;
if (env.NODE_ENV !== "production") globalForDb.pool = pool;
export const db = drizzle(client, { schema });
export const db = drizzle(pool, { schema });

View File

@@ -1,5 +1,5 @@
import { relations, sql } from "drizzle-orm";
import { index, primaryKey, sqliteTableCreator } from "drizzle-orm/sqlite-core";
import { index, primaryKey, pgTableCreator } from "drizzle-orm/pg-core";
import { type AdapterAccount } from "next-auth/adapters";
/**
@@ -8,20 +8,20 @@ import { type AdapterAccount } from "next-auth/adapters";
*
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
*/
export const createTable = sqliteTableCreator((name) => `beenvoice_${name}`);
export const createTable = pgTableCreator((name) => `beenvoice_${name}`);
// Auth-related tables (keeping existing)
export const users = createTable("user", (d) => ({
id: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: d.text({ length: 255 }),
email: d.text({ length: 255 }).notNull(),
password: d.text({ length: 255 }),
emailVerified: d.integer({ mode: "timestamp" }).default(sql`(unixepoch())`),
image: d.text({ length: 255 }),
name: d.varchar({ length: 255 }),
email: d.varchar({ length: 255 }).notNull(),
password: d.varchar({ length: 255 }),
emailVerified: d.timestamp().default(sql`CURRENT_TIMESTAMP`),
image: d.varchar({ length: 255 }),
}));
export const usersRelations = relations(users, ({ many }) => ({
@@ -35,19 +35,19 @@ export const accounts = createTable(
"account",
(d) => ({
userId: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
type: d.text({ length: 255 }).$type<AdapterAccount["type"]>().notNull(),
provider: d.text({ length: 255 }).notNull(),
providerAccountId: d.text({ length: 255 }).notNull(),
type: d.varchar({ length: 255 }).$type<AdapterAccount["type"]>().notNull(),
provider: d.varchar({ length: 255 }).notNull(),
providerAccountId: d.varchar({ length: 255 }).notNull(),
refresh_token: d.text(),
access_token: d.text(),
expires_at: d.integer(),
token_type: d.text({ length: 255 }),
scope: d.text({ length: 255 }),
token_type: d.varchar({ length: 255 }),
scope: d.varchar({ length: 255 }),
id_token: d.text(),
session_state: d.text({ length: 255 }),
session_state: d.varchar({ length: 255 }),
}),
(t) => [
primaryKey({
@@ -64,12 +64,12 @@ export const accountsRelations = relations(accounts, ({ one }) => ({
export const sessions = createTable(
"session",
(d) => ({
sessionToken: d.text({ length: 255 }).notNull().primaryKey(),
sessionToken: d.varchar({ length: 255 }).notNull().primaryKey(),
userId: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
expires: d.integer({ mode: "timestamp" }).notNull(),
expires: d.timestamp().notNull(),
}),
(t) => [index("session_userId_idx").on(t.userId)],
);
@@ -81,9 +81,9 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({
export const verificationTokens = createTable(
"verification_token",
(d) => ({
identifier: d.text({ length: 255 }).notNull(),
token: d.text({ length: 255 }).notNull(),
expires: d.integer({ mode: "timestamp" }).notNull(),
identifier: d.varchar({ length: 255 }).notNull(),
token: d.varchar({ length: 255 }).notNull(),
expires: d.timestamp().notNull(),
}),
(t) => [primaryKey({ columns: [t.identifier, t.token] })],
);
@@ -93,29 +93,29 @@ export const clients = createTable(
"client",
(d) => ({
id: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: d.text({ length: 255 }).notNull(),
email: d.text({ length: 255 }),
phone: d.text({ length: 50 }),
addressLine1: d.text({ length: 255 }),
addressLine2: d.text({ length: 255 }),
city: d.text({ length: 100 }),
state: d.text({ length: 50 }),
postalCode: d.text({ length: 20 }),
country: d.text({ length: 100 }),
name: d.varchar({ length: 255 }).notNull(),
email: d.varchar({ length: 255 }),
phone: d.varchar({ length: 50 }),
addressLine1: d.varchar({ length: 255 }),
addressLine2: d.varchar({ length: 255 }),
city: d.varchar({ length: 100 }),
state: d.varchar({ length: 50 }),
postalCode: d.varchar({ length: 20 }),
country: d.varchar({ length: 100 }),
defaultHourlyRate: d.real().notNull().default(100.0),
createdById: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
createdAt: d
.integer({ mode: "timestamp" })
.default(sql`(unixepoch())`)
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
updatedAt: d.timestamp().$onUpdate(() => new Date()),
}),
(t) => [
index("client_created_by_idx").on(t.createdById),
@@ -136,32 +136,36 @@ export const businesses = createTable(
"business",
(d) => ({
id: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: d.text({ length: 255 }).notNull(),
email: d.text({ length: 255 }),
phone: d.text({ length: 50 }),
addressLine1: d.text({ length: 255 }),
addressLine2: d.text({ length: 255 }),
city: d.text({ length: 100 }),
state: d.text({ length: 50 }),
postalCode: d.text({ length: 20 }),
country: d.text({ length: 100 }),
website: d.text({ length: 255 }),
taxId: d.text({ length: 100 }),
logoUrl: d.text({ length: 500 }),
isDefault: d.integer({ mode: "boolean" }).default(false),
name: d.varchar({ length: 255 }).notNull(),
email: d.varchar({ length: 255 }),
phone: d.varchar({ length: 50 }),
addressLine1: d.varchar({ length: 255 }),
addressLine2: d.varchar({ length: 255 }),
city: d.varchar({ length: 100 }),
state: d.varchar({ length: 50 }),
postalCode: d.varchar({ length: 20 }),
country: d.varchar({ length: 100 }),
website: d.varchar({ length: 255 }),
taxId: d.varchar({ length: 100 }),
logoUrl: d.varchar({ length: 500 }),
isDefault: d.boolean().default(false),
// Email configuration for custom Resend setup
resendApiKey: d.varchar({ length: 255 }),
resendDomain: d.varchar({ length: 255 }),
emailFromName: d.varchar({ length: 255 }),
createdById: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
createdAt: d
.integer({ mode: "timestamp" })
.default(sql`(unixepoch())`)
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
updatedAt: d.timestamp().$onUpdate(() => new Date()),
}),
(t) => [
index("business_created_by_idx").on(t.createdById),
@@ -183,31 +187,31 @@ export const invoices = createTable(
"invoice",
(d) => ({
id: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
invoiceNumber: d.text({ length: 100 }).notNull(),
businessId: d.text({ length: 255 }).references(() => businesses.id),
invoiceNumber: d.varchar({ length: 100 }).notNull(),
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
clientId: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.references(() => clients.id),
issueDate: d.integer({ mode: "timestamp" }).notNull(),
dueDate: d.integer({ mode: "timestamp" }).notNull(),
status: d.text({ length: 50 }).notNull().default("draft"), // draft, sent, paid, overdue
issueDate: d.timestamp().notNull(),
dueDate: d.timestamp().notNull(),
status: d.varchar({ length: 50 }).notNull().default("draft"), // draft, sent, paid (overdue computed)
totalAmount: d.real().notNull().default(0),
taxRate: d.real().notNull().default(0.0),
notes: d.text({ length: 1000 }),
notes: d.varchar({ length: 1000 }),
createdById: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
createdAt: d
.integer({ mode: "timestamp" })
.default(sql`(unixepoch())`)
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
updatedAt: d.timestamp().$onUpdate(() => new Date()),
}),
(t) => [
index("invoice_business_id_idx").on(t.businessId),
@@ -238,23 +242,23 @@ export const invoiceItems = createTable(
"invoice_item",
(d) => ({
id: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
invoiceId: d
.text({ length: 255 })
.varchar({ length: 255 })
.notNull()
.references(() => invoices.id, { onDelete: "cascade" }),
date: d.integer({ mode: "timestamp" }).notNull(),
description: d.text({ length: 500 }).notNull(),
date: d.timestamp().notNull(),
description: d.varchar({ length: 500 }).notNull(),
hours: d.real().notNull(),
rate: d.real().notNull(),
amount: d.real().notNull(),
position: d.integer().notNull().default(0), // NEW: position for ordering
createdAt: d
.integer({ mode: "timestamp" })
.default(sql`(unixepoch())`)
.timestamp()
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
}),
(t) => [

38
src/types/invoice.ts Normal file
View File

@@ -0,0 +1,38 @@
export type StoredInvoiceStatus = "draft" | "sent" | "paid";
export type EffectiveInvoiceStatus = "draft" | "sent" | "paid" | "overdue";
export interface Invoice {
id: string;
invoiceNumber: string;
businessId: string | null;
clientId: string;
issueDate: Date;
dueDate: Date;
status: StoredInvoiceStatus;
totalAmount: number;
taxRate: number;
notes: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date | null;
}
export interface InvoiceWithRelations extends Invoice {
client: {
id: string;
name: string;
email: string | null;
};
business: {
id: string;
name: string;
email: string | null;
} | null;
invoiceItems: Array<{
id: string;
date: Date;
description: string;
hours: number;
rate: number;
}>;
}