mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-11 08:34:43 -05:00
Build fixes, email preview system
This commit is contained in:
23
.cursorrules
23
.cursorrules
@@ -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.
|
||||
|
||||
@@ -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=""
|
||||
|
||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal 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
281
docs/email-features.md
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE `beenvoice_invoice_item` ADD COLUMN `position` integer DEFAULT 0 NOT NULL;
|
||||
CREATE INDEX `invoice_item_position_idx` ON `beenvoice_invoice_item` (`position`);
|
||||
@@ -1,125 +0,0 @@
|
||||
CREATE TABLE `beenvoice_account` (
|
||||
`userId` text(255) NOT NULL,
|
||||
`type` text(255) NOT NULL,
|
||||
`provider` text(255) NOT NULL,
|
||||
`providerAccountId` text(255) NOT NULL,
|
||||
`refresh_token` text,
|
||||
`access_token` text,
|
||||
`expires_at` integer,
|
||||
`token_type` text(255),
|
||||
`scope` text(255),
|
||||
`id_token` text,
|
||||
`session_state` text(255),
|
||||
PRIMARY KEY(`provider`, `providerAccountId`),
|
||||
FOREIGN KEY (`userId`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `account_user_id_idx` ON `beenvoice_account` (`userId`);--> statement-breakpoint
|
||||
CREATE TABLE `beenvoice_business` (
|
||||
`id` text(255) PRIMARY KEY NOT NULL,
|
||||
`name` text(255) NOT NULL,
|
||||
`email` text(255),
|
||||
`phone` text(50),
|
||||
`addressLine1` text(255),
|
||||
`addressLine2` text(255),
|
||||
`city` text(100),
|
||||
`state` text(50),
|
||||
`postalCode` text(20),
|
||||
`country` text(100),
|
||||
`website` text(255),
|
||||
`taxId` text(100),
|
||||
`logoUrl` text(500),
|
||||
`isDefault` integer DEFAULT false,
|
||||
`createdById` text(255) NOT NULL,
|
||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updatedAt` integer,
|
||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `business_created_by_idx` ON `beenvoice_business` (`createdById`);--> statement-breakpoint
|
||||
CREATE INDEX `business_name_idx` ON `beenvoice_business` (`name`);--> statement-breakpoint
|
||||
CREATE INDEX `business_email_idx` ON `beenvoice_business` (`email`);--> statement-breakpoint
|
||||
CREATE INDEX `business_is_default_idx` ON `beenvoice_business` (`isDefault`);--> statement-breakpoint
|
||||
CREATE TABLE `beenvoice_client` (
|
||||
`id` text(255) PRIMARY KEY NOT NULL,
|
||||
`name` text(255) NOT NULL,
|
||||
`email` text(255),
|
||||
`phone` text(50),
|
||||
`addressLine1` text(255),
|
||||
`addressLine2` text(255),
|
||||
`city` text(100),
|
||||
`state` text(50),
|
||||
`postalCode` text(20),
|
||||
`country` text(100),
|
||||
`createdById` text(255) NOT NULL,
|
||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updatedAt` integer,
|
||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `client_created_by_idx` ON `beenvoice_client` (`createdById`);--> statement-breakpoint
|
||||
CREATE INDEX `client_name_idx` ON `beenvoice_client` (`name`);--> statement-breakpoint
|
||||
CREATE INDEX `client_email_idx` ON `beenvoice_client` (`email`);--> statement-breakpoint
|
||||
CREATE TABLE `beenvoice_invoice_item` (
|
||||
`id` text(255) PRIMARY KEY NOT NULL,
|
||||
`invoiceId` text(255) NOT NULL,
|
||||
`date` integer NOT NULL,
|
||||
`description` text(500) NOT NULL,
|
||||
`hours` real NOT NULL,
|
||||
`rate` real NOT NULL,
|
||||
`amount` real NOT NULL,
|
||||
`position` integer DEFAULT 0 NOT NULL,
|
||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`invoiceId`) REFERENCES `beenvoice_invoice`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `invoice_item_invoice_id_idx` ON `beenvoice_invoice_item` (`invoiceId`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_item_date_idx` ON `beenvoice_invoice_item` (`date`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_item_position_idx` ON `beenvoice_invoice_item` (`position`);--> statement-breakpoint
|
||||
CREATE TABLE `beenvoice_invoice` (
|
||||
`id` text(255) PRIMARY KEY NOT NULL,
|
||||
`invoiceNumber` text(100) NOT NULL,
|
||||
`businessId` text(255),
|
||||
`clientId` text(255) NOT NULL,
|
||||
`issueDate` integer NOT NULL,
|
||||
`dueDate` integer NOT NULL,
|
||||
`status` text(50) DEFAULT 'draft' NOT NULL,
|
||||
`totalAmount` real DEFAULT 0 NOT NULL,
|
||||
`taxRate` real DEFAULT 0 NOT NULL,
|
||||
`notes` text(1000),
|
||||
`createdById` text(255) NOT NULL,
|
||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updatedAt` integer,
|
||||
FOREIGN KEY (`businessId`) REFERENCES `beenvoice_business`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`clientId`) REFERENCES `beenvoice_client`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `invoice_business_id_idx` ON `beenvoice_invoice` (`businessId`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_client_id_idx` ON `beenvoice_invoice` (`clientId`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_created_by_idx` ON `beenvoice_invoice` (`createdById`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_number_idx` ON `beenvoice_invoice` (`invoiceNumber`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_status_idx` ON `beenvoice_invoice` (`status`);--> statement-breakpoint
|
||||
CREATE TABLE `beenvoice_session` (
|
||||
`sessionToken` text(255) PRIMARY KEY NOT NULL,
|
||||
`userId` text(255) NOT NULL,
|
||||
`expires` integer NOT NULL,
|
||||
FOREIGN KEY (`userId`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `session_userId_idx` ON `beenvoice_session` (`userId`);--> statement-breakpoint
|
||||
CREATE TABLE `beenvoice_user` (
|
||||
`id` text(255) PRIMARY KEY NOT NULL,
|
||||
`name` text(255),
|
||||
`email` text(255) NOT NULL,
|
||||
`password` text(255),
|
||||
`emailVerified` integer DEFAULT (unixepoch()),
|
||||
`image` text(255)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `beenvoice_verification_token` (
|
||||
`identifier` text(255) NOT NULL,
|
||||
`token` text(255) NOT NULL,
|
||||
`expires` integer NOT NULL,
|
||||
PRIMARY KEY(`identifier`, `token`)
|
||||
);
|
||||
130
drizzle/0000_warm_squadron_sinister.sql
Normal file
130
drizzle/0000_warm_squadron_sinister.sql
Normal 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");
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE `beenvoice_invoice` ADD COLUMN `taxRate` real NOT NULL DEFAULT 0;
|
||||
UPDATE `beenvoice_invoice` SET `taxRate` = 0 WHERE `taxRate` IS NULL;
|
||||
@@ -1,29 +0,0 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_beenvoice_invoice` (
|
||||
`id` text(255) PRIMARY KEY NOT NULL,
|
||||
`invoiceNumber` text(100) NOT NULL,
|
||||
`businessId` text(255),
|
||||
`clientId` text(255) NOT NULL,
|
||||
`issueDate` integer NOT NULL,
|
||||
`dueDate` integer NOT NULL,
|
||||
`status` text(50) DEFAULT 'draft' NOT NULL,
|
||||
`totalAmount` real DEFAULT 0 NOT NULL,
|
||||
`taxRate` real DEFAULT 0 NOT NULL,
|
||||
`notes` text(1000),
|
||||
`createdById` text(255) NOT NULL,
|
||||
`createdAt` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updatedAt` integer,
|
||||
FOREIGN KEY (`businessId`) REFERENCES `beenvoice_business`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`clientId`) REFERENCES `beenvoice_client`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`createdById`) REFERENCES `beenvoice_user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_beenvoice_invoice`("id", "invoiceNumber", "businessId", "clientId", "issueDate", "dueDate", "status", "totalAmount", "taxRate", "notes", "createdById", "createdAt", "updatedAt") SELECT "id", "invoiceNumber", "businessId", "clientId", "issueDate", "dueDate", "status", "totalAmount", "taxRate", "notes", "createdById", "createdAt", "updatedAt" FROM `beenvoice_invoice`;--> statement-breakpoint
|
||||
DROP TABLE `beenvoice_invoice`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_beenvoice_invoice` RENAME TO `beenvoice_invoice`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE INDEX `invoice_business_id_idx` ON `beenvoice_invoice` (`businessId`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_client_id_idx` ON `beenvoice_invoice` (`clientId`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_created_by_idx` ON `beenvoice_invoice` (`createdById`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_number_idx` ON `beenvoice_invoice` (`invoiceNumber`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_status_idx` ON `beenvoice_invoice` (`status`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
8182
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { auth } from "~/server/auth";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ClientForm } from "~/components/forms/client-form";
|
||||
import Link from "next/link";
|
||||
|
||||
interface EditClientPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function EditClientPage({ params }: EditClientPageProps) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">Access Denied</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Please sign in to edit clients
|
||||
</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button size="lg">Sign In</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
<div className="p-6">
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-2 text-3xl font-bold">Edit Client</h2>
|
||||
<p className="text-muted-foreground">Update client information</p>
|
||||
</div>
|
||||
<ClientForm mode="edit" clientId={id} />
|
||||
</div>
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Navbar } from "~/components/layout/navbar";
|
||||
import { Sidebar } from "~/components/layout/sidebar";
|
||||
|
||||
export default function ClientsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<main className="flex-1 min-h-screen bg-background">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { auth } from "~/server/auth";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ClientForm } from "~/components/forms/client-form";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function NewClientPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Access Denied</h1>
|
||||
<p className="text-muted-foreground mb-8">Please sign in to create clients</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button size="lg">Sign In</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
<div className="p-6">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold mb-2">Add New Client</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Create a new client profile
|
||||
</p>
|
||||
</div>
|
||||
<ClientForm mode="create" />
|
||||
</div>
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "~/server/auth";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ClientList } from "~/components/data/client-list";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
export default async function ClientsPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Access Denied</h1>
|
||||
<p className="text-muted-foreground mb-8">Please sign in to view clients</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button size="lg">Sign In</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prefetch clients data
|
||||
void api.clients.getAll.prefetch();
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
<div className="p-6">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold mb-2">Clients</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your client relationships
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ClientList />
|
||||
</div>
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
343
src/app/dashboard/_components/status-manager.tsx
Normal file
343
src/app/dashboard/_components/status-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{businessToDelete?.name}" and remove all
|
||||
associated data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import { BusinessForm } from "~/components/forms/business-form";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
|
||||
export default function NewBusinessPage() {
|
||||
return (
|
||||
<div className="space-y-6 pb-32">
|
||||
<PageHeader
|
||||
title="Add Business"
|
||||
description="Enter business details below to add a new business."
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<HydrateClient>
|
||||
<BusinessForm mode="create" />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
<HydrateClient>
|
||||
<BusinessForm mode="create" />
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "{clientToDelete?.name}" and remove all
|
||||
associated data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
511
src/app/dashboard/invoices/[id]/send/page.tsx
Normal file
511
src/app/dashboard/invoices/[id]/send/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { DataTable } from "~/components/data/data-table";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTableColumnHeader } from "~/components/data/data-table";
|
||||
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
|
||||
import Link from "next/link";
|
||||
|
||||
// Sample data type
|
||||
interface DemoItem {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Generate sample data
|
||||
const sampleData: DemoItem[] = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `item-${i + 1}`,
|
||||
name: `Item ${i + 1}`,
|
||||
email: `item${i + 1}@example.com`,
|
||||
phone: `555-${String(Math.floor(Math.random() * 9000) + 1000)}`,
|
||||
status: ["active", "pending", "inactive"][
|
||||
Math.floor(Math.random() * 3)
|
||||
] as string,
|
||||
createdAt: new Date(Date.now() - Math.random() * 10000000000),
|
||||
}));
|
||||
|
||||
// Define columns with responsive behavior
|
||||
const columns: ColumnDef<DemoItem>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.name}</p>
|
||||
<p className="text-muted-foreground text-xs">{row.original.email}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "phone",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Phone" />
|
||||
),
|
||||
cell: ({ row }) => row.original.phone,
|
||||
meta: {
|
||||
headerClassName: "hidden md:table-cell",
|
||||
cellClassName: "hidden md:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
|
||||
row.original.status === "active"
|
||||
? "bg-green-100 text-green-700"
|
||||
: row.original.status === "pending"
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{row.original.status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt") as Date;
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden lg:table-cell",
|
||||
cellClassName: "hidden lg:table-cell",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default function TableLayoutDemo() {
|
||||
const [data] = useState(sampleData);
|
||||
|
||||
const filterableColumns = [
|
||||
{
|
||||
id: "status",
|
||||
title: "Status",
|
||||
options: [
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Pending", value: "pending" },
|
||||
{ label: "Inactive", value: "inactive" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10">
|
||||
<DashboardBreadcrumbs />
|
||||
|
||||
<PageHeader
|
||||
title="Table Layout & Breadcrumb Demo"
|
||||
description="This demo showcases the improved responsive layouts and dynamic breadcrumbs. The breadcrumbs automatically handle pluralization and capitalization. Navigate to different pages to see how they adapt."
|
||||
variant="gradient"
|
||||
>
|
||||
<Button variant="brand" size="lg">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
Add Item
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="mt-8">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
searchPlaceholder="Search items..."
|
||||
filterableColumns={filterableColumns}
|
||||
showColumnVisibility={true}
|
||||
showPagination={true}
|
||||
showSearch={true}
|
||||
pageSize={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 space-y-4">
|
||||
<h2 className="text-2xl font-bold">Layout Improvements</h2>
|
||||
<div className="text-muted-foreground space-y-2 text-sm">
|
||||
<p>
|
||||
• Page header: Description text wraps below the title and action
|
||||
buttons
|
||||
</p>
|
||||
<p>
|
||||
• Filter bar: Search and filters stay inline on mobile with proper
|
||||
wrapping
|
||||
</p>
|
||||
<p>
|
||||
• Pagination bar: Entry count and controls remain on the same line
|
||||
on mobile
|
||||
</p>
|
||||
<p>
|
||||
• Columns: Responsive hiding with both headers and cells hidden
|
||||
together
|
||||
</p>
|
||||
<p>
|
||||
• Compact design: Tighter padding for more efficient space usage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-8 text-2xl font-bold">Dynamic Breadcrumbs</h2>
|
||||
<div className="text-muted-foreground space-y-2 text-sm">
|
||||
<p>
|
||||
• Automatic pluralization: "Business" becomes "Businesses" on list
|
||||
pages
|
||||
</p>
|
||||
<p>• Smart capitalization: Route segments are properly capitalized</p>
|
||||
<p>• Context awareness: Shows resource names instead of UUIDs</p>
|
||||
<p>
|
||||
• Clean presentation: Edit pages show the resource name, not "Edit"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm font-medium">Try these example routes:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/dashboard/businesses">
|
||||
<Button variant="outline" size="sm">
|
||||
Businesses List
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/dashboard/clients">
|
||||
<Button variant="outline" size="sm">
|
||||
Clients List
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/dashboard/invoices">
|
||||
<Button variant="outline" size="sm">
|
||||
Invoices List
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Navbar } from "~/components/layout/navbar";
|
||||
import { Sidebar } from "~/components/layout/sidebar";
|
||||
|
||||
export default function InvoicesLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<main className="bg-background min-h-screen flex-1">{children}</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "~/server/auth";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { InvoiceList } from "~/components/data/invoice-list";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
export default async function InvoicesPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">Access Denied</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Please sign in to view invoices
|
||||
</p>
|
||||
<Link href="/api/auth/signin">
|
||||
<Button size="lg">Sign In</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prefetch invoices data
|
||||
void api.invoices.getAll.prefetch();
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
<div className="p-6">
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-2 text-3xl font-bold">Invoices</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your invoices and payments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InvoiceList />
|
||||
</div>
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
© 2025 Sean O'Connor.
|
||||
© 2025 Sean O'Connor.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
345
src/components/forms/email-composer.tsx
Normal file
345
src/components/forms/email-composer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
src/components/forms/email-preview.tsx
Normal file
167
src/components/forms/email-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/components/forms/enhanced-send-invoice-button.tsx
Normal file
98
src/components/forms/enhanced-send-invoice-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
451
src/components/forms/send-email-dialog.tsx
Normal file
451
src/components/forms/send-email-dialog.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal 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 }
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
/**
|
||||
|
||||
1
src/lib/email-templates/index.ts
Normal file
1
src/lib/email-templates/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { generateInvoiceEmailTemplate } from "./invoice-email";
|
||||
578
src/lib/email-templates/invoice-email.ts
Normal file
578
src/lib/email-templates/invoice-email.ts
Normal 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
84
src/lib/email-utils.ts
Normal 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";
|
||||
}
|
||||
@@ -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
137
src/lib/invoice-status.ts
Normal 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);
|
||||
}
|
||||
@@ -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"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
311
src/server/api/routers/email.ts
Normal file
311
src/server/api/routers/email.ts
Normal 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(),
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
38
src/types/invoice.ts
Normal 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;
|
||||
}>;
|
||||
}
|
||||
Reference in New Issue
Block a user