mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-13 01:24:44 -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
|
- Protected routes require authentication
|
||||||
- Follow NextAuth.js security best practices
|
- Follow NextAuth.js security best practices
|
||||||
|
|
||||||
|
### Development Tools
|
||||||
|
- Use ESLint and Prettier for code formatting
|
||||||
|
- Use TypeScript for type safety
|
||||||
|
- Exclusively use bun for development and production. Do not use Node.js or Deno.
|
||||||
|
- Stay away from starting development servers or running builds unless absolutely necessary.
|
||||||
|
- Run lints and typechecks when helpful.
|
||||||
|
|
||||||
## Component Architecture
|
## Component Architecture
|
||||||
|
|
||||||
### UI Components (shadcn/ui)
|
### UI Components (shadcn/ui)
|
||||||
@@ -111,21 +118,21 @@ beenvoice is a professional invoicing application built with the T3 stack (Next.
|
|||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
// Required props
|
// Required props
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
// Optional props with defaults
|
// Optional props with defaults
|
||||||
variant?: "default" | "success" | "warning" | "error";
|
variant?: "default" | "success" | "warning" | "error";
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
|
|
||||||
// Styling props
|
// Styling props
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
|
||||||
// Accessibility
|
// Accessibility
|
||||||
"aria-label"?: string;
|
"aria-label"?: string;
|
||||||
}
|
}
|
||||||
@@ -237,7 +244,7 @@ export const exampleRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// Business logic here
|
// Business logic here
|
||||||
}),
|
}),
|
||||||
|
|
||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
.input(z.object({ /* pagination/filtering */ }))
|
.input(z.object({ /* pagination/filtering */ }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
@@ -425,10 +432,10 @@ export const exampleRouter = createTRPCRouter({
|
|||||||
- Document emergency procedures
|
- Document emergency procedures
|
||||||
|
|
||||||
## Remember
|
## Remember
|
||||||
This is a business application where reliability, security, and professional user experience are critical. Every decision should prioritize these values over development convenience or flashy features.
|
This is a business application where reliability, security, and professional user experience are critical. Every decision should prioritize these values over development convenience or flashy features.
|
||||||
|
|
||||||
- Don't create demo pages unless absolutely necessary.
|
- Don't create demo pages unless absolutely necessary.
|
||||||
- Don't create unnecessary complexity.
|
- Don't create unnecessary complexity.
|
||||||
- Don't run builds unless absolutely necessary, if you do, kill the dev servers.
|
- Don't run builds unless absolutely necessary, if you do, kill the dev servers.
|
||||||
- Don't start new dev servers unless asked.
|
- Don't start new dev servers unless asked.
|
||||||
- Don't start drizzle studio- you cannot do anything with it.
|
- Don't start drizzle studio- you cannot do anything with it.
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
# https://next-auth.js.org/configuration/options#secret
|
# https://next-auth.js.org/configuration/options#secret
|
||||||
AUTH_SECRET=""
|
AUTH_SECRET=""
|
||||||
|
|
||||||
# Next Auth Discord Provider
|
|
||||||
AUTH_DISCORD_ID=""
|
|
||||||
AUTH_DISCORD_SECRET=""
|
|
||||||
|
|
||||||
# Drizzle
|
# Drizzle
|
||||||
DATABASE_URL="file:./db.sqlite"
|
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 {
|
export default {
|
||||||
schema: "./src/server/db/schema.ts",
|
schema: "./src/server/db/schema.ts",
|
||||||
dialect: "sqlite",
|
dialect: "postgresql",
|
||||||
dbCredentials: env.DATABASE_AUTH_TOKEN
|
dbCredentials: {
|
||||||
? {
|
url: env.DATABASE_URL,
|
||||||
url: env.DATABASE_URL,
|
},
|
||||||
token: env.DATABASE_AUTH_TOKEN,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
url: env.DATABASE_URL,
|
|
||||||
},
|
|
||||||
tablesFilter: ["beenvoice_*"],
|
tablesFilter: ["beenvoice_*"],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@@ -1,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",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "postgresql",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "7",
|
||||||
"when": 1752275489999,
|
"when": 1753825898609,
|
||||||
"tag": "0000_unique_loa",
|
"tag": "0000_warm_squadron_sinister",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,10 +5,6 @@
|
|||||||
import "./src/env.js";
|
import "./src/env.js";
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {};
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default 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:push-direct": "node scripts/migrate-direct.js",
|
||||||
"db:export-data": "node scripts/export-data.js",
|
"db:export-data": "node scripts/export-data.js",
|
||||||
"db:import-data": "node scripts/import-data-final.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",
|
"deploy": "drizzle-kit push && next build",
|
||||||
"dev": "next dev --turbo",
|
"dev": "next dev --turbo",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
@@ -30,7 +32,6 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@libsql/client": "^0.14.0",
|
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-collapsible": "^1.1.11",
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
@@ -49,31 +50,41 @@
|
|||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
"@tanstack/react-query": "^5.69.0",
|
"@tanstack/react-query": "^5.69.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@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/client": "^11.0.0",
|
||||||
"@trpc/react-query": "^11.0.0",
|
"@trpc/react-query": "^11.0.0",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.0.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
|
"@types/pg": "^8.15.5",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"chrono-node": "^2.8.3",
|
"chrono-node": "^2.8.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.44.4",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"lucide": "^0.525.0",
|
"lucide": "^0.525.0",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "^15.4.2",
|
"next": "^15.4.4",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"next-auth": "5.0.0-beta.25",
|
||||||
|
"pg": "^8.16.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^9.8.0",
|
"react-day-picker": "^9.8.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
|
"resend": "^4.7.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"turso": "^0.1.0",
|
"turso": "^0.1.0",
|
||||||
|
"vercel": "^44.6.4",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -49,29 +49,35 @@ function RegisterForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-container">
|
<div className="floating-orbs flex min-h-screen items-center justify-center p-4">
|
||||||
<div className="auth-form-container">
|
<div className="w-full max-w-md space-y-8">
|
||||||
{/* Logo and Welcome */}
|
{/* Logo and Welcome */}
|
||||||
<div className="auth-header">
|
<div className="space-y-4 text-center">
|
||||||
<Logo size="lg" className="mx-auto" />
|
<Logo size="lg" className="mx-auto" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="auth-title">Join beenvoice</h1>
|
<h1 className="text-foreground text-2xl font-bold">
|
||||||
<p className="auth-subtitle">Create your account to get started</p>
|
Join beenvoice
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Create your account to get started
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Registration Form */}
|
{/* Registration Form */}
|
||||||
<Card className="auth-card">
|
<Card className="card-primary">
|
||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-1">
|
||||||
<CardTitle className="auth-card-title">Create Account</CardTitle>
|
<CardTitle className="text-center text-xl">
|
||||||
|
Create Account
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleRegister} className="auth-form">
|
<form onSubmit={handleRegister} className="space-y-4">
|
||||||
<div className="auth-input-grid">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="auth-input-group">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="firstName">First Name</Label>
|
<Label htmlFor="firstName">First Name</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="auth-input-icon" />
|
<User className="form-icon-left" />
|
||||||
<Input
|
<Input
|
||||||
id="firstName"
|
id="firstName"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -84,10 +90,10 @@ function RegisterForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-input-group">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="lastName">Last Name</Label>
|
<Label htmlFor="lastName">Last Name</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="auth-input-icon" />
|
<User className="form-icon-left" />
|
||||||
<Input
|
<Input
|
||||||
id="lastName"
|
id="lastName"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -100,10 +106,10 @@ function RegisterForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-input-group">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="auth-input-icon" />
|
<Mail className="form-icon-left" />
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -115,10 +121,10 @@ function RegisterForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-input-group">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="auth-input-icon" />
|
<Lock className="form-icon-left" />
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -130,15 +136,11 @@ function RegisterForm() {
|
|||||||
placeholder="Create a password"
|
placeholder="Create a password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="auth-password-help">
|
<p className="text-muted-foreground text-xs">
|
||||||
Must be at least 6 characters
|
Must be at least 6 characters
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
type="submit"
|
|
||||||
className="auth-submit-btn"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
"Creating account..."
|
"Creating account..."
|
||||||
) : (
|
) : (
|
||||||
@@ -149,11 +151,11 @@ function RegisterForm() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<div className="auth-footer-text">
|
<div className="mt-6 text-center text-sm">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
</span>
|
</span>
|
||||||
<Link href="/auth/signin" className="auth-footer-link">
|
<Link href="/auth/signin" className="nav-link-brand">
|
||||||
Sign in here
|
Sign in here
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,9 +163,9 @@ function RegisterForm() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<div className="auth-features">
|
<div className="space-y-4 text-center">
|
||||||
<p className="welcome-description">Start invoicing like a pro</p>
|
<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>✓ Free to start</span>
|
||||||
<span>✓ No credit card</span>
|
<span>✓ No credit card</span>
|
||||||
<span>✓ Cancel anytime</span>
|
<span>✓ Cancel anytime</span>
|
||||||
@@ -178,13 +180,15 @@ export default function RegisterPage() {
|
|||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="auth-container">
|
<div className="floating-orbs flex min-h-screen items-center justify-center p-4">
|
||||||
<div className="auth-form-container">
|
<div className="w-full max-w-md space-y-8">
|
||||||
<div className="auth-header">
|
<div className="space-y-4 text-center">
|
||||||
<Logo size="lg" className="mx-auto" />
|
<Logo size="lg" className="mx-auto" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="auth-title">Join beenvoice</h1>
|
<h1 className="text-foreground text-2xl font-bold">
|
||||||
<p className="auth-subtitle">Loading...</p>
|
Join beenvoice
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function SignInForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="w-full max-w-md space-y-8">
|
||||||
{/* Logo and Welcome */}
|
{/* Logo and Welcome */}
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
@@ -135,7 +135,7 @@ export default function SignInPage() {
|
|||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
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="w-full max-w-md space-y-8">
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
<Logo size="lg" className="mx-auto" />
|
<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>
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
business "{businessToDelete?.name}" and remove all associated
|
business "{businessToDelete?.name}" and remove all
|
||||||
data.
|
associated data.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { BusinessForm } from "~/components/forms/business-form";
|
import { BusinessForm } from "~/components/forms/business-form";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
import { HydrateClient } from "~/trpc/server";
|
import { HydrateClient } from "~/trpc/server";
|
||||||
|
|
||||||
export default function NewBusinessPage() {
|
export default function NewBusinessPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<HydrateClient>
|
||||||
<PageHeader
|
<BusinessForm mode="create" />
|
||||||
title="Add Business"
|
</HydrateClient>
|
||||||
description="Enter business details below to add a new business."
|
|
||||||
variant="gradient"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HydrateClient>
|
|
||||||
<BusinessForm mode="create" />
|
|
||||||
</HydrateClient>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
|
import { Plus } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { 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 { 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
|
// Businesses Table Component
|
||||||
async function BusinessesTable() {
|
async function BusinessesTable() {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
|
||||||
interface ClientDetailPageProps {
|
interface ClientDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -222,7 +224,7 @@ export default async function ClientDetailPage({
|
|||||||
{client.invoices.slice(0, 3).map((invoice) => (
|
{client.invoices.slice(0, 3).map((invoice) => (
|
||||||
<div
|
<div
|
||||||
key={invoice.id}
|
key={invoice.id}
|
||||||
className="card-secondary 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>
|
<div>
|
||||||
<p className="text-foreground font-medium">
|
<p className="text-foreground font-medium">
|
||||||
@@ -238,15 +240,29 @@ export default async function ClientDetailPage({
|
|||||||
</p>
|
</p>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
invoice.status === "paid"
|
getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
) === "paid"
|
||||||
? "default"
|
? "default"
|
||||||
: invoice.status === "sent"
|
: getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
) === "sent"
|
||||||
? "secondary"
|
? "secondary"
|
||||||
: "outline"
|
: getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
) === "overdue"
|
||||||
|
? "destructive"
|
||||||
|
: "outline"
|
||||||
}
|
}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{invoice.status}
|
{getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function ClientsDataTable({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium">{client.name}</p>
|
<p className="truncate font-medium">{client.name}</p>
|
||||||
<p className="text-muted-foreground truncate text-sm">
|
<p className="text-muted-foreground truncate text-sm">
|
||||||
{client.email || "—"}
|
{client.email ?? "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +108,7 @@ export function ClientsDataTable({
|
|||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Phone" />
|
<DataTableColumnHeader column={column} title="Phone" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => row.original.phone || "—",
|
cell: ({ row }) => row.original.phone ?? "—",
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "hidden md:table-cell",
|
headerClassName: "hidden md:table-cell",
|
||||||
cellClassName: "hidden md:table-cell",
|
cellClassName: "hidden md:table-cell",
|
||||||
@@ -148,9 +148,9 @@ export function ClientsDataTable({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
data-action-button="true"
|
data-action-button="true"
|
||||||
>
|
>
|
||||||
@@ -192,7 +192,8 @@ export function ClientsDataTable({
|
|||||||
<DialogTitle>Are you sure?</DialogTitle>
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
client "{clientToDelete?.name}" and remove all associated data.
|
client "{clientToDelete?.name}" and remove all
|
||||||
|
associated data.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<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 { Plus } from "lucide-react";
|
||||||
import { ClientsTable } from "./_components/clients-table";
|
import Link from "next/link";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
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() {
|
export default async function ClientsPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,27 +4,68 @@ import { useState } from "react";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
|
||||||
import { Send, Loader2 } from "lucide-react";
|
import { Send, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
interface SendInvoiceButtonProps {
|
interface SendInvoiceButtonProps {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
variant?: "default" | "outline" | "ghost" | "icon";
|
variant?: "default" | "outline" | "ghost" | "icon";
|
||||||
className?: string;
|
className?: string;
|
||||||
|
showResend?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SendInvoiceButton({
|
export function SendInvoiceButton({
|
||||||
invoiceId,
|
invoiceId,
|
||||||
variant = "outline",
|
variant = "outline",
|
||||||
className,
|
className,
|
||||||
|
showResend = false,
|
||||||
}: SendInvoiceButtonProps) {
|
}: SendInvoiceButtonProps) {
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
|
||||||
// Fetch invoice data when sending is triggered
|
// Get utils for cache invalidation
|
||||||
const { refetch: fetchInvoice } = api.invoices.getById.useQuery(
|
const utils = api.useUtils();
|
||||||
{ id: invoiceId },
|
|
||||||
{ enabled: false },
|
// 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 () => {
|
const handleSendInvoice = async () => {
|
||||||
if (isSending) return;
|
if (isSending) return;
|
||||||
@@ -32,88 +73,12 @@ export function SendInvoiceButton({
|
|||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch fresh invoice data
|
await sendInvoiceMutation.mutateAsync({
|
||||||
const { data: invoice } = await fetchInvoice();
|
invoiceId,
|
||||||
|
});
|
||||||
if (!invoice) {
|
|
||||||
throw new Error("Invoice not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate PDF blob for potential attachment
|
|
||||||
const pdfBlob = await generateInvoicePDFBlob(invoice);
|
|
||||||
|
|
||||||
// Create a temporary download URL for the PDF
|
|
||||||
const pdfUrl = URL.createObjectURL(pdfBlob);
|
|
||||||
|
|
||||||
// Format currency
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format date
|
|
||||||
const formatDate = (date: Date) => {
|
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
}).format(new Date(date));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate days until due
|
|
||||||
const today = new Date();
|
|
||||||
const dueDate = new Date(invoice.dueDate);
|
|
||||||
const daysUntilDue = Math.ceil(
|
|
||||||
(dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create professional email template
|
|
||||||
const subject = `Invoice ${invoice.invoiceNumber} - ${formatCurrency(invoice.totalAmount)}`;
|
|
||||||
|
|
||||||
const body = `Dear ${invoice.client.name},
|
|
||||||
|
|
||||||
I hope this email finds you well. Please find attached invoice ${invoice.invoiceNumber} for the services provided.
|
|
||||||
|
|
||||||
Invoice Details:
|
|
||||||
• Invoice Number: ${invoice.invoiceNumber}
|
|
||||||
• Issue Date: ${formatDate(invoice.issueDate)}
|
|
||||||
• Due Date: ${formatDate(invoice.dueDate)}
|
|
||||||
• Amount Due: ${formatCurrency(invoice.totalAmount)}
|
|
||||||
${daysUntilDue > 0 ? `• Payment Due: In ${daysUntilDue} days` : daysUntilDue === 0 ? `• Payment Due: Today` : `• Status: ${Math.abs(daysUntilDue)} days overdue`}
|
|
||||||
|
|
||||||
${invoice.notes ? `\nAdditional Notes:\n${invoice.notes}\n` : ""}
|
|
||||||
Please review the attached invoice and remit payment by the due date. If you have any questions or concerns regarding this invoice, please don't hesitate to contact me.
|
|
||||||
|
|
||||||
Thank you for your business!
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
${invoice.business?.name ?? "Your Business Name"}
|
|
||||||
${invoice.business?.email ? `\n${invoice.business.email}` : ""}
|
|
||||||
${invoice.business?.phone ? `\n${invoice.business.phone}` : ""}`;
|
|
||||||
|
|
||||||
// Create mailto link
|
|
||||||
const mailtoLink = `mailto:${invoice.client.email ?? ""}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
||||||
|
|
||||||
// Create a temporary link element to trigger mailto
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = mailtoLink;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
|
|
||||||
// Clean up the PDF URL object
|
|
||||||
URL.revokeObjectURL(pdfUrl);
|
|
||||||
|
|
||||||
toast.success("Email client opened with invoice details");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Error is already handled by the mutation's onError
|
||||||
console.error("Send invoice error:", error);
|
console.error("Send invoice error:", error);
|
||||||
toast.error(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to prepare invoice email",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
}
|
}
|
||||||
@@ -149,12 +114,12 @@ ${invoice.business?.phone ? `\n${invoice.business.phone}` : ""}`;
|
|||||||
{isSending ? (
|
{isSending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<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" />
|
<Send className="mr-2 h-4 w-4" />
|
||||||
<span>Send Invoice</span>
|
<span>{showResend ? "Resend Invoice" : "Send Invoice"}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { DollarSign, Edit, Loader2, Trash2 } from "lucide-react";
|
||||||
import { notFound, useRouter, useParams } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { api } from "~/trpc/react";
|
import { notFound, useParams, useRouter } from "next/navigation";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { useState } from "react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { toast } from "sonner";
|
||||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||||
import { Separator } from "~/components/ui/separator";
|
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
import { PDFDownloadButton } from "../_components/pdf-download-button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { SendInvoiceButton } from "../_components/send-invoice-button";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { InvoiceDetailsSkeleton } from "../_components/invoice-details-skeleton";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -20,19 +17,26 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/components/ui/dialog";
|
} 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 {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
Building,
|
Building,
|
||||||
Edit,
|
Check,
|
||||||
FileText,
|
FileText,
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Phone,
|
Phone,
|
||||||
User,
|
User,
|
||||||
AlertTriangle,
|
|
||||||
Check,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||||
@@ -42,8 +46,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
|
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
|
||||||
id: invoiceId,
|
id: invoiceId,
|
||||||
});
|
});
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
// Delete mutation
|
|
||||||
const deleteInvoice = api.invoices.delete.useMutation({
|
const deleteInvoice = api.invoices.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Invoice deleted successfully");
|
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 = () => {
|
const handleDelete = () => {
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMarkAsPaid = () => {
|
||||||
|
updateStatus.mutate({
|
||||||
|
id: invoiceId,
|
||||||
|
status: "paid" as StoredInvoiceStatus,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
deleteInvoice.mutate({ id: invoiceId });
|
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 subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||||
const total = subtotal + taxAmount;
|
const total = subtotal + taxAmount;
|
||||||
const isOverdue =
|
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||||
new Date(invoice.dueDate) < new Date() && invoice.status !== "paid";
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
);
|
||||||
|
const isOverdue = isInvoiceOverdue(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
);
|
||||||
|
|
||||||
const getStatusType = (): StatusType => {
|
const getStatusType = (): StatusType => {
|
||||||
if (invoice.status === "paid") return "paid";
|
return effectiveStatus as StatusType;
|
||||||
if (invoice.status === "draft") return "draft";
|
|
||||||
if (invoice.status === "overdue") return "overdue";
|
|
||||||
if (invoice.status === "sent") {
|
|
||||||
return isOverdue ? "overdue" : "sent";
|
|
||||||
}
|
|
||||||
return "draft";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -401,8 +422,38 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
<PDFDownloadButton invoiceId={invoice.id} className="w-full" />
|
<PDFDownloadButton invoiceId={invoice.id} className="w-full" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{invoice.status === "draft" && (
|
{/* Send Invoice Button - Show for draft, sent, and overdue */}
|
||||||
<SendInvoiceButton invoiceId={invoice.id} className="w-full" />
|
{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
|
<Button
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
import { Eye, Edit, Trash2 } from "lucide-react";
|
import { Eye, Edit, Trash2 } from "lucide-react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
|
||||||
// Type for invoice data
|
// Type for invoice data
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
@@ -65,14 +67,10 @@ interface InvoicesDataTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getStatusType = (invoice: Invoice): StatusType => {
|
const getStatusType = (invoice: Invoice): StatusType => {
|
||||||
if (invoice.status === "paid") return "paid";
|
return getEffectiveInvoiceStatus(
|
||||||
if (invoice.status === "draft") return "draft";
|
invoice.status as StoredInvoiceStatus,
|
||||||
if (invoice.status === "overdue") return "overdue";
|
invoice.dueDate,
|
||||||
if (invoice.status === "sent") {
|
) as StatusType;
|
||||||
const dueDate = new Date(invoice.dueDate);
|
|
||||||
return dueDate < new Date() ? "overdue" : "sent";
|
|
||||||
}
|
|
||||||
return "draft";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
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 {
|
import {
|
||||||
ArrowLeft,
|
|
||||||
Upload,
|
|
||||||
FileText,
|
|
||||||
Download,
|
|
||||||
CheckCircle,
|
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Info,
|
ArrowLeft,
|
||||||
|
CheckCircle,
|
||||||
|
Download,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
|
FileText,
|
||||||
|
Info,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} 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
|
// File Upload Instructions Component
|
||||||
function FormatInstructions() {
|
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 {
|
import {
|
||||||
Users,
|
Activity,
|
||||||
FileText,
|
|
||||||
DollarSign,
|
|
||||||
TrendingUp,
|
|
||||||
Plus,
|
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
|
BarChart3,
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
Eye,
|
DollarSign,
|
||||||
Edit,
|
Edit,
|
||||||
Activity,
|
Eye,
|
||||||
BarChart3,
|
FileText,
|
||||||
|
Plus,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} 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
|
// Modern gradient background component
|
||||||
function DashboardHero({ firstName }: { firstName: string }) {
|
function DashboardHero({ firstName }: { firstName: string }) {
|
||||||
@@ -46,10 +47,22 @@ async function DashboardStats() {
|
|||||||
const totalClients = clients.length;
|
const totalClients = clients.length;
|
||||||
const totalInvoices = invoices.length;
|
const totalInvoices = invoices.length;
|
||||||
const totalRevenue = invoices
|
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);
|
.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
||||||
const pendingAmount = invoices
|
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);
|
.reduce((sum, invoice) => sum + invoice.totalAmount, 0);
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
@@ -197,7 +210,11 @@ function QuickActions() {
|
|||||||
async function CurrentWork() {
|
async function CurrentWork() {
|
||||||
const invoices = await api.invoices.getAll();
|
const invoices = await api.invoices.getAll();
|
||||||
const draftInvoices = invoices.filter(
|
const draftInvoices = invoices.filter(
|
||||||
(invoice) => invoice.status === "draft",
|
(invoice) =>
|
||||||
|
getEffectiveInvoiceStatus(
|
||||||
|
invoice.status as StoredInvoiceStatus,
|
||||||
|
invoice.dueDate,
|
||||||
|
) === "draft",
|
||||||
);
|
);
|
||||||
const currentInvoice = draftInvoices[0];
|
const currentInvoice = draftInvoices[0];
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Key,
|
Key,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
FileUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
@@ -59,6 +60,7 @@ export function SettingsContent() {
|
|||||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||||
const [importData, setImportData] = useState("");
|
const [importData, setImportData] = useState("");
|
||||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||||
|
const [importMethod, setImportMethod] = useState<"file" | "paste">("file");
|
||||||
|
|
||||||
// Password change state
|
// Password change state
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
@@ -182,76 +184,60 @@ export function SettingsContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Type guard for backup data
|
// Type guard for backup data
|
||||||
const isValidBackupData = (
|
const isValidBackupData = (data: unknown): boolean => {
|
||||||
data: unknown,
|
if (typeof data !== "object" || data === null) return false;
|
||||||
): data is {
|
|
||||||
exportDate: string;
|
const obj = data as Record<string, unknown>;
|
||||||
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;
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
} => {
|
|
||||||
return !!(
|
return !!(
|
||||||
data &&
|
obj.exportDate &&
|
||||||
typeof data === "object" &&
|
obj.version &&
|
||||||
data !== null &&
|
obj.user &&
|
||||||
"exportDate" in data &&
|
obj.clients &&
|
||||||
"version" in data &&
|
obj.businesses &&
|
||||||
"user" in data &&
|
obj.invoices &&
|
||||||
"clients" in data &&
|
Array.isArray(obj.clients) &&
|
||||||
"businesses" in data &&
|
Array.isArray(obj.businesses) &&
|
||||||
"invoices" in data
|
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 = () => {
|
const handleImportData = () => {
|
||||||
try {
|
try {
|
||||||
const parsedData: unknown = JSON.parse(importData);
|
const parsedData: unknown = JSON.parse(importData);
|
||||||
|
|
||||||
if (isValidBackupData(parsedData)) {
|
if (isValidBackupData(parsedData)) {
|
||||||
|
// @ts-expect-error Server handles validation of backup data format
|
||||||
importDataMutation.mutate(parsedData);
|
importDataMutation.mutate(parsedData);
|
||||||
} else {
|
} else {
|
||||||
toast.error("Invalid backup file format");
|
toast.error("Invalid backup file format");
|
||||||
@@ -536,37 +522,95 @@ export function SettingsContent() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Import Backup Data</DialogTitle>
|
<DialogTitle>Import Backup Data</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Paste the contents of your backup JSON file below. This
|
Upload your backup JSON file or paste the contents below.
|
||||||
will add the data to your existing account.
|
This will add the data to your existing account.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Textarea
|
{/* Import Method Selector */}
|
||||||
placeholder="Paste your backup JSON data here..."
|
<div className="flex gap-2">
|
||||||
value={importData}
|
<Button
|
||||||
onChange={(e) => setImportData(e.target.value)}
|
type="button"
|
||||||
rows={12}
|
variant={
|
||||||
className="font-mono text-sm"
|
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>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setIsImportDialogOpen(false)}
|
onClick={() => {
|
||||||
|
setIsImportDialogOpen(false);
|
||||||
|
setImportData("");
|
||||||
|
setImportMethod("file");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{importMethod === "paste" && (
|
||||||
onClick={handleImportData}
|
<Button
|
||||||
disabled={
|
onClick={handleImportData}
|
||||||
!importData.trim() || importDataMutation.isPending
|
disabled={
|
||||||
}
|
!importData.trim() || importDataMutation.isPending
|
||||||
className="btn-brand-primary"
|
}
|
||||||
>
|
className="btn-brand-primary"
|
||||||
{importDataMutation.isPending
|
>
|
||||||
? "Importing..."
|
{importDataMutation.isPending
|
||||||
: "Import Data"}
|
? "Importing..."
|
||||||
</Button>
|
: "Import Data"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -581,6 +625,7 @@ export function SettingsContent() {
|
|||||||
<li>
|
<li>
|
||||||
• Import adds to existing data without replacing anything
|
• Import adds to existing data without replacing anything
|
||||||
</li>
|
</li>
|
||||||
|
<li>• Upload JSON files directly or paste content manually</li>
|
||||||
<li>• Store backup files in a secure, accessible location</li>
|
<li>• Store backup files in a secure, accessible location</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
||||||
<div className="mt-6 border-t border-slate-200 pt-6 sm:mt-8 sm:pt-8 dark:border-slate-700">
|
<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">
|
<p className="text-sm text-slate-600 sm:text-base dark:text-slate-400">
|
||||||
© 2025 Sean O'Connor.
|
© 2025 Sean O'Connor.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { cn } from "~/lib/utils";
|
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -18,8 +17,8 @@ export function Logo({ className, size = "md" }: LogoProps) {
|
|||||||
<Image
|
<Image
|
||||||
src="/beenvoice-logo.svg"
|
src="/beenvoice-logo.svg"
|
||||||
alt="beenvoice logo"
|
alt="beenvoice logo"
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
className={className}
|
className={className}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Calendar, Clock, Edit, Eye, FileText, Plus, User } from "lucide-react";
|
||||||
import Link from "next/link";
|
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 { 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 { Skeleton } from "~/components/ui/skeleton";
|
||||||
import {
|
import { api } from "~/trpc/react";
|
||||||
FileText,
|
|
||||||
Clock,
|
|
||||||
Plus,
|
|
||||||
Edit,
|
|
||||||
Eye,
|
|
||||||
DollarSign,
|
|
||||||
User,
|
|
||||||
Calendar,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
export function CurrentOpenInvoiceCard() {
|
export function CurrentOpenInvoiceCard() {
|
||||||
const { data: currentInvoice, isLoading } =
|
const { data: currentInvoice, isLoading } =
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import type {
|
import type {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
@@ -18,23 +17,25 @@ import {
|
|||||||
import {
|
import {
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Search,
|
|
||||||
Filter,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronsLeft,
|
ChevronsLeft,
|
||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
|
Filter,
|
||||||
|
Search,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "~/components/ui/dropdown-menu";
|
} from "~/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -50,7 +51,6 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
interface DataTableProps<TData, TValue> {
|
interface DataTableProps<TData, TValue> {
|
||||||
@@ -77,7 +77,7 @@ interface DataTableProps<TData, TValue> {
|
|||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData, TValue>({
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
searchKey,
|
searchKey: _searchKey,
|
||||||
searchPlaceholder = "Search...",
|
searchPlaceholder = "Search...",
|
||||||
showColumnVisibility = true,
|
showColumnVisibility = true,
|
||||||
showPagination = true,
|
showPagination = true,
|
||||||
@@ -511,7 +511,7 @@ export function DataTable<TData, TValue>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper component for sortable column headers
|
// Helper component for sortable column headers
|
||||||
export function DataTableColumnHeader<TData, TValue>({
|
export function DataTableColumnHeader({
|
||||||
column,
|
column,
|
||||||
title,
|
title,
|
||||||
className,
|
className,
|
||||||
@@ -552,7 +552,7 @@ export function DataTableColumnHeader<TData, TValue>({
|
|||||||
|
|
||||||
// Export skeleton component for loading states
|
// Export skeleton component for loading states
|
||||||
export function DataTableSkeleton({
|
export function DataTableSkeleton({
|
||||||
columns = 5,
|
columns: _columns = 5,
|
||||||
rows = 5,
|
rows = 5,
|
||||||
}: {
|
}: {
|
||||||
columns?: number;
|
columns?: number;
|
||||||
|
|||||||
@@ -89,9 +89,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
|||||||
deleteInvoice.mutate({ id: invoiceId });
|
deleteInvoice.mutate({ id: invoiceId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusUpdate = (
|
const handleStatusUpdate = (newStatus: "draft" | "sent" | "paid") => {
|
||||||
newStatus: "draft" | "sent" | "paid" | "overdue",
|
|
||||||
) => {
|
|
||||||
updateStatus.mutate({ id: invoiceId, status: newStatus });
|
updateStatus.mutate({ id: invoiceId, status: newStatus });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function AddressForm({
|
|||||||
className = "",
|
className = "",
|
||||||
}: AddressFormProps) {
|
}: AddressFormProps) {
|
||||||
const handlePostalCodeChange = (value: string) => {
|
const handlePostalCodeChange = (value: string) => {
|
||||||
const formatted = formatPostalCode(value, country || "US");
|
const formatted = formatPostalCode(value, country ?? "US");
|
||||||
onChange("postalCode", formatted);
|
onChange("postalCode", formatted);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ export function AddressForm({
|
|||||||
key={`state-${state}`}
|
key={`state-${state}`}
|
||||||
id="state"
|
id="state"
|
||||||
options={stateOptions}
|
options={stateOptions}
|
||||||
value={state || ""}
|
value={state ?? ""}
|
||||||
onValueChange={(value) => onChange("state", value)}
|
onValueChange={(value) => onChange("state", value)}
|
||||||
placeholder="Select a state"
|
placeholder="Select a state"
|
||||||
className={errors.state ? "border-destructive" : ""}
|
className={errors.state ? "border-destructive" : ""}
|
||||||
@@ -194,7 +194,7 @@ export function AddressForm({
|
|||||||
key={`country-${country}`}
|
key={`country-${country}`}
|
||||||
id="country"
|
id="country"
|
||||||
options={countryOptions}
|
options={countryOptions}
|
||||||
value={country || ""}
|
value={country ?? ""}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
// Don't save the placeholder value
|
// Don't save the placeholder value
|
||||||
if (value !== "__placeholder__") {
|
if (value !== "__placeholder__") {
|
||||||
@@ -218,7 +218,8 @@ export function AddressForm({
|
|||||||
return option.label;
|
return option.label;
|
||||||
}}
|
}}
|
||||||
isOptionDisabled={(option) =>
|
isOptionDisabled={(option) =>
|
||||||
option.disabled || option.value?.startsWith("divider-")
|
(option.disabled ?? false) ||
|
||||||
|
(option.value?.startsWith("divider-") ?? false)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{errors.country && (
|
{errors.country && (
|
||||||
|
|||||||
@@ -1,40 +1,43 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Building,
|
|
||||||
Mail,
|
|
||||||
Phone,
|
|
||||||
Save,
|
|
||||||
Globe,
|
|
||||||
BadgeDollarSign,
|
|
||||||
Image,
|
|
||||||
Star,
|
|
||||||
Loader2,
|
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
Building,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
FileText,
|
FileText,
|
||||||
|
Globe,
|
||||||
|
Info,
|
||||||
|
Key,
|
||||||
|
Loader2,
|
||||||
|
Mail,
|
||||||
|
Save,
|
||||||
|
Star,
|
||||||
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
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 { 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 { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { Switch } from "~/components/ui/switch";
|
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 {
|
import {
|
||||||
formatPhoneNumber,
|
formatPhoneNumber,
|
||||||
formatWebsiteUrl,
|
|
||||||
formatTaxId,
|
formatTaxId,
|
||||||
|
formatWebsiteUrl,
|
||||||
isValidEmail,
|
isValidEmail,
|
||||||
VALIDATION_MESSAGES,
|
|
||||||
PLACEHOLDERS,
|
PLACEHOLDERS,
|
||||||
|
VALIDATION_MESSAGES,
|
||||||
} from "~/lib/form-constants";
|
} from "~/lib/form-constants";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
interface BusinessFormProps {
|
interface BusinessFormProps {
|
||||||
businessId?: string;
|
businessId?: string;
|
||||||
@@ -53,8 +56,10 @@ interface FormData {
|
|||||||
country: string;
|
country: string;
|
||||||
website: string;
|
website: string;
|
||||||
taxId: string;
|
taxId: string;
|
||||||
logoUrl: string;
|
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
|
resendApiKey: string;
|
||||||
|
resendDomain: string;
|
||||||
|
emailFromName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormErrors {
|
interface FormErrors {
|
||||||
@@ -68,6 +73,9 @@ interface FormErrors {
|
|||||||
country?: string;
|
country?: string;
|
||||||
website?: string;
|
website?: string;
|
||||||
taxId?: string;
|
taxId?: string;
|
||||||
|
resendApiKey?: string;
|
||||||
|
resendDomain?: string;
|
||||||
|
emailFromName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialFormData: FormData = {
|
const initialFormData: FormData = {
|
||||||
@@ -82,8 +90,10 @@ const initialFormData: FormData = {
|
|||||||
country: "United States",
|
country: "United States",
|
||||||
website: "",
|
website: "",
|
||||||
taxId: "",
|
taxId: "",
|
||||||
logoUrl: "",
|
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
|
resendApiKey: "",
|
||||||
|
resendDomain: "",
|
||||||
|
emailFromName: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||||
@@ -91,6 +101,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
|
||||||
// Fetch business data if editing
|
// Fetch business data if editing
|
||||||
@@ -100,6 +111,23 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
{ enabled: mode === "edit" && !!businessId },
|
{ 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({
|
const createBusiness = api.businesses.create.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Business created successfully");
|
toast.success("Business created successfully");
|
||||||
@@ -135,11 +163,13 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
country: business.country ?? "United States",
|
country: business.country ?? "United States",
|
||||||
website: business.website ?? "",
|
website: business.website ?? "",
|
||||||
taxId: business.taxId ?? "",
|
taxId: business.taxId ?? "",
|
||||||
logoUrl: business.logoUrl ?? "",
|
|
||||||
isDefault: business.isDefault ?? false,
|
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) => {
|
const handleInputChange = (field: string, value: string | boolean) => {
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
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);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
};
|
};
|
||||||
@@ -224,12 +284,73 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (mode === "create") {
|
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 {
|
} 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({
|
await updateBusiness.mutateAsync({
|
||||||
id: businessId!,
|
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 {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -246,7 +367,10 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
router.push("/dashboard/businesses");
|
router.push("/dashboard/businesses");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === "edit" && isLoadingBusiness) {
|
if (
|
||||||
|
(mode === "edit" && isLoadingBusiness) ||
|
||||||
|
(mode === "edit" && isLoadingEmailConfig)
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<div className="space-y-6 pb-32">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -488,6 +612,189 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Settings */}
|
||||||
<Card className="card-primary">
|
<Card className="card-primary">
|
||||||
<CardHeader>
|
<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 * as React from "react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone, type FileRejection } from "react-dropzone";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Upload, FileText, X, CheckCircle, AlertCircle } from "lucide-react";
|
import { Upload, FileText, X, CheckCircle, AlertCircle } from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -98,7 +98,7 @@ export function FileUpload({
|
|||||||
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(acceptedFiles: File[], rejectedFiles: any[]) => {
|
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||||
// Handle accepted files
|
// Handle accepted files
|
||||||
const newFiles = [...files, ...acceptedFiles];
|
const newFiles = [...files, ...acceptedFiles];
|
||||||
setFiles(newFiles);
|
setFiles(newFiles);
|
||||||
@@ -106,19 +106,19 @@ export function FileUpload({
|
|||||||
|
|
||||||
// Handle rejected files
|
// Handle rejected files
|
||||||
const newErrors: Record<string, string> = { ...errors };
|
const newErrors: Record<string, string> = { ...errors };
|
||||||
rejectedFiles.forEach(({ file, errors }) => {
|
rejectedFiles.forEach(({ file, errors: fileErrors }) => {
|
||||||
const errorMessage = errors
|
const errorMessage = fileErrors
|
||||||
.map((e: any) => {
|
.map((error) => {
|
||||||
if (e.code === "file-too-large") {
|
if (error.code === "file-too-large") {
|
||||||
return `File is too large. Max size is ${(maxSize / 1024 / 1024).toFixed(1)}MB`;
|
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";
|
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 `Too many files. Max is ${maxFiles}`;
|
||||||
}
|
}
|
||||||
return e.message;
|
return error.message;
|
||||||
})
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
newErrors[file.name] = errorMessage;
|
newErrors[file.name] = errorMessage;
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ const STATUS_OPTIONS = [
|
|||||||
{ value: "draft", label: "Draft" },
|
{ value: "draft", label: "Draft" },
|
||||||
{ value: "sent", label: "Sent" },
|
{ value: "sent", label: "Sent" },
|
||||||
{ value: "paid", label: "Paid" },
|
{ value: "paid", label: "Paid" },
|
||||||
{ value: "overdue", label: "Overdue" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface InvoiceFormProps {
|
interface InvoiceFormProps {
|
||||||
@@ -60,7 +59,7 @@ interface FormData {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
issueDate: Date;
|
issueDate: Date;
|
||||||
dueDate: Date;
|
dueDate: Date;
|
||||||
status: "draft" | "sent" | "paid" | "overdue";
|
status: "draft" | "sent" | "paid";
|
||||||
notes: string;
|
notes: string;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
defaultHourlyRate: number;
|
defaultHourlyRate: number;
|
||||||
@@ -158,7 +157,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
clientId: existingInvoice.clientId,
|
clientId: existingInvoice.clientId,
|
||||||
issueDate: new Date(existingInvoice.issueDate),
|
issueDate: new Date(existingInvoice.issueDate),
|
||||||
dueDate: new Date(existingInvoice.dueDate),
|
dueDate: new Date(existingInvoice.dueDate),
|
||||||
status: existingInvoice.status as "draft" | "sent" | "paid" | "overdue",
|
status: existingInvoice.status as "draft" | "sent" | "paid",
|
||||||
notes: existingInvoice.notes ?? "",
|
notes: existingInvoice.notes ?? "",
|
||||||
taxRate: existingInvoice.taxRate,
|
taxRate: existingInvoice.taxRate,
|
||||||
defaultHourlyRate: 25,
|
defaultHourlyRate: 25,
|
||||||
@@ -523,9 +522,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
<Label htmlFor="status">Status</Label>
|
<Label htmlFor="status">Status</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.status}
|
value={formData.status}
|
||||||
onValueChange={(
|
onValueChange={(value: "draft" | "sent" | "paid") =>
|
||||||
value: "draft" | "sent" | "paid" | "overdue",
|
updateField("status", value)
|
||||||
) => updateField("status", value)}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|||||||
@@ -1,23 +1,8 @@
|
|||||||
"use client";
|
"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 {
|
import {
|
||||||
Trash2,
|
|
||||||
Plus,
|
|
||||||
GripVertical,
|
|
||||||
ChevronUp,
|
|
||||||
ChevronDown,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn } from "~/lib/utils";
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
@@ -28,10 +13,24 @@ import {
|
|||||||
arrayMove,
|
arrayMove,
|
||||||
SortableContext,
|
SortableContext,
|
||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
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 {
|
interface InvoiceItem {
|
||||||
id: string;
|
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";
|
"use client";
|
||||||
|
import { signOut, useSession } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSession, signOut } from "next-auth/react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
|
||||||
import { Logo } from "~/components/branding/logo";
|
import { Logo } from "~/components/branding/logo";
|
||||||
import { SidebarTrigger } from "~/components/navigation/sidebar-trigger";
|
import { SidebarTrigger } from "~/components/navigation/sidebar-trigger";
|
||||||
import { api } from "~/trpc/react";
|
import { Button } from "~/components/ui/button";
|
||||||
import { FileText, Edit } from "lucide-react";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
|
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
|
||||||
|
|
||||||
// Get current open invoice for quick access
|
// Get current open invoice for quick access
|
||||||
const { data: currentInvoice } = api.invoices.getCurrentOpen.useQuery();
|
// const { data: currentInvoice } = api.invoices.getCurrentOpen.useQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-2 right-2 left-2 z-30 md:top-3 md:right-3 md:left-3">
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 md:gap-4">
|
<div className="flex items-center gap-2 md:gap-4">
|
||||||
|
|
||||||
{status === "loading" ? (
|
{status === "loading" ? (
|
||||||
<>
|
<>
|
||||||
<Skeleton className="bg-muted/20 hidden h-5 w-20 sm:inline" />
|
<Skeleton className="bg-muted/20 hidden h-5 w-20 sm:inline" />
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ export function PageHeader({
|
|||||||
|
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case "gradient":
|
case "gradient":
|
||||||
return `${baseClasses} text-3xl text-brand-gradient`;
|
return `${baseClasses} text-3xl text-foreground`;
|
||||||
case "large":
|
case "large":
|
||||||
return `${baseClasses} text-4xl text-foreground`;
|
return `${baseClasses} text-4xl text-foreground`;
|
||||||
case "large-gradient":
|
case "large-gradient":
|
||||||
return `${baseClasses} text-4xl text-brand-gradient`;
|
return `${baseClasses} text-4xl text-foreground`;
|
||||||
default:
|
default:
|
||||||
return `${baseClasses} text-3xl text-foreground`;
|
return `${baseClasses} text-3xl text-foreground`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
"use client";
|
"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 {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
BreadcrumbLink,
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from "~/components/ui/breadcrumb";
|
} 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 { 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) {
|
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(
|
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 resourceType = segments[1]; // e.g., 'clients', 'invoices', 'businesses'
|
||||||
const resourceId =
|
const resourceId =
|
||||||
segments[2] && isUUID(segments[2]) ? segments[2] : undefined;
|
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
|
// Fetch client data if needed
|
||||||
const { data: client, isLoading: clientLoading } =
|
const { data: client, isLoading: clientLoading } =
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
|
||||||
import { MenuIcon, X } from "lucide-react";
|
import { MenuIcon, X } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useSession } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
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";
|
import { navigationConfig } from "~/lib/navigation";
|
||||||
|
|
||||||
interface SidebarTriggerProps {
|
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,
|
modifiers,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DayButton>) {
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
const _defaultClassNames = getDefaultClassNames();
|
// const _defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
const ref = React.useRef<HTMLButtonElement>(null);
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
function Progress({
|
function Progress({
|
||||||
className,
|
className,
|
||||||
@@ -15,17 +15,17 @@ function Progress({
|
|||||||
data-slot="progress"
|
data-slot="progress"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ProgressPrimitive.Indicator
|
<ProgressPrimitive.Indicator
|
||||||
data-slot="progress-indicator"
|
data-slot="progress-indicator"
|
||||||
className="bg-primary h-full w-full flex-1 transition-all"
|
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>
|
</ProgressPrimitive.Root>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Progress }
|
export { Progress };
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ export const env = createEnv({
|
|||||||
? z.string()
|
? z.string()
|
||||||
: z.string().optional(),
|
: z.string().optional(),
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
DATABASE_AUTH_TOKEN:
|
RESEND_API_KEY: z.string().min(1),
|
||||||
process.env.NODE_ENV === "production"
|
RESEND_DOMAIN: z.string().optional(),
|
||||||
? z.string()
|
|
||||||
: z.string().optional(),
|
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(["development", "test", "production"])
|
.enum(["development", "test", "production"])
|
||||||
.default("development"),
|
.default("development"),
|
||||||
@@ -37,7 +35,8 @@ export const env = createEnv({
|
|||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
AUTH_SECRET: process.env.AUTH_SECRET,
|
AUTH_SECRET: process.env.AUTH_SECRET,
|
||||||
DATABASE_URL: process.env.DATABASE_URL,
|
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,
|
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) return "";
|
||||||
|
|
||||||
// If URL doesn't start with http:// or https://, add https://
|
// If URL doesn't start with http:// or https://, add https://
|
||||||
if (!url.match(/^https?:\/\//i)) {
|
if (!/^https?:\/\//i.exec(url)) {
|
||||||
return `https://${url}`;
|
return `https://${url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ export function formatWebsiteUrl(url: string): string {
|
|||||||
// Postal code formatting
|
// Postal code formatting
|
||||||
export function formatPostalCode(
|
export function formatPostalCode(
|
||||||
value: string,
|
value: string,
|
||||||
country: string = "United States",
|
country = "United States",
|
||||||
): string {
|
): string {
|
||||||
if (country === "United States") {
|
if (country === "United States") {
|
||||||
// Format as US ZIP code (12345 or 12345-6789)
|
// Format as US ZIP code (12345 or 12345-6789)
|
||||||
@@ -340,7 +340,7 @@ export function formatPostalCode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tax ID formatting
|
// 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, "");
|
const digits = value.replace(/\D/g, "");
|
||||||
|
|
||||||
if (type === "EIN") {
|
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 { saveAs } from "file-saver";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
// Register Inter font
|
// Global font registration state
|
||||||
Font.register({
|
let fontsRegistered = false;
|
||||||
family: "Inter",
|
|
||||||
src: "/fonts/inter/Inter-Variable.ttf",
|
|
||||||
fontWeight: "normal",
|
|
||||||
});
|
|
||||||
|
|
||||||
Font.register({
|
// Font registration helper that works in both client and server environments
|
||||||
family: "Inter",
|
const registerFonts = () => {
|
||||||
src: "/fonts/inter/Inter-Italic-Variable.ttf",
|
try {
|
||||||
fontStyle: "italic",
|
// Avoid duplicate registration
|
||||||
});
|
if (fontsRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Register Azeret Mono fonts for numbers and tables - multiple weights
|
// Only register custom fonts on client side for now
|
||||||
Font.register({
|
// Server-side will use fallback fonts to avoid path/loading issues
|
||||||
family: "AzeretMono",
|
if (typeof window !== "undefined") {
|
||||||
src: "/fonts/azeret/AzeretMono-Regular.ttf",
|
// Register Inter font
|
||||||
fontWeight: "normal",
|
Font.register({
|
||||||
});
|
family: "Inter",
|
||||||
|
src: "/fonts/inter/Inter-Variable.ttf",
|
||||||
|
fontWeight: "normal",
|
||||||
|
});
|
||||||
|
|
||||||
Font.register({
|
Font.register({
|
||||||
family: "AzeretMono",
|
family: "Inter",
|
||||||
src: "/fonts/azeret/AzeretMono-Regular.ttf",
|
src: "/fonts/inter/Inter-Italic-Variable.ttf",
|
||||||
fontWeight: "semibold",
|
fontStyle: "italic",
|
||||||
});
|
});
|
||||||
|
|
||||||
Font.register({
|
// Register Azeret Mono fonts for numbers and tables - multiple weights
|
||||||
family: "AzeretMono",
|
Font.register({
|
||||||
src: "/fonts/azeret/AzeretMono-Regular.ttf",
|
family: "AzeretMono",
|
||||||
fontWeight: "bold",
|
src: "/fonts/azeret/AzeretMono-Regular.ttf",
|
||||||
});
|
fontWeight: "normal",
|
||||||
|
});
|
||||||
|
|
||||||
Font.register({
|
Font.register({
|
||||||
family: "AzeretMono",
|
family: "AzeretMono",
|
||||||
src: "/fonts/azeret/AzeretMono-Italic-Variable.ttf",
|
src: "/fonts/azeret/AzeretMono-Regular.ttf",
|
||||||
fontStyle: "italic",
|
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 {
|
interface InvoiceData {
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
@@ -94,7 +119,7 @@ const styles = StyleSheet.create({
|
|||||||
page: {
|
page: {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
fontFamily: "Inter",
|
fontFamily: "Helvetica",
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
paddingTop: 40,
|
paddingTop: 40,
|
||||||
paddingBottom: 80,
|
paddingBottom: 80,
|
||||||
@@ -121,16 +146,18 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
businessName: {
|
businessName: {
|
||||||
fontSize: 24,
|
fontFamily: "Helvetica-Bold",
|
||||||
fontWeight: "bold",
|
fontSize: 18,
|
||||||
color: "#111827",
|
color: "#111827",
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
|
|
||||||
businessInfo: {
|
businessInfo: {
|
||||||
fontSize: 11,
|
fontSize: 10,
|
||||||
|
fontFamily: "Helvetica",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
marginBottom: 2,
|
lineHeight: 1.3,
|
||||||
|
marginBottom: 3,
|
||||||
},
|
},
|
||||||
|
|
||||||
businessAddress: {
|
businessAddress: {
|
||||||
@@ -147,15 +174,14 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
invoiceTitle: {
|
invoiceTitle: {
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: "bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
color: "#10b981",
|
color: "#10b981",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
|
|
||||||
invoiceNumber: {
|
invoiceNumber: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: "semibold",
|
fontFamily: "Courier-Bold",
|
||||||
fontFamily: "AzeretMono",
|
|
||||||
color: "#111827",
|
color: "#111827",
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
@@ -165,7 +191,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: "bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -193,21 +219,23 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: "bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
color: "#111827",
|
color: "#111827",
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
|
|
||||||
clientName: {
|
clientName: {
|
||||||
fontSize: 13,
|
fontFamily: "Helvetica-Bold",
|
||||||
fontWeight: "bold",
|
fontSize: 14,
|
||||||
color: "#111827",
|
color: "#111827",
|
||||||
marginBottom: 4,
|
marginBottom: 2,
|
||||||
},
|
},
|
||||||
|
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
fontSize: 11,
|
fontSize: 10,
|
||||||
|
fontFamily: "Helvetica",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
|
lineHeight: 1.3,
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -232,9 +260,8 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
detailValue: {
|
detailValue: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "AzeretMono",
|
fontFamily: "Courier-Bold",
|
||||||
color: "#111827",
|
color: "#111827",
|
||||||
fontWeight: "semibold",
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
textAlign: "right",
|
textAlign: "right",
|
||||||
},
|
},
|
||||||
@@ -251,17 +278,25 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
notesTitle: {
|
notesTitle: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: "bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
color: "#111827",
|
color: "#111827",
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
},
|
},
|
||||||
|
|
||||||
notesContent: {
|
notesContent: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
|
fontFamily: "Helvetica",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
businessContact: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontFamily: "Helvetica",
|
||||||
|
color: "#6b7280",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
},
|
||||||
|
|
||||||
// Separator styles
|
// Separator styles
|
||||||
headerSeparator: {
|
headerSeparator: {
|
||||||
height: 1,
|
height: 1,
|
||||||
@@ -280,8 +315,8 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
abridgedBusinessName: {
|
abridgedBusinessName: {
|
||||||
fontSize: 18,
|
fontSize: 12,
|
||||||
fontWeight: "bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
color: "#111827",
|
color: "#111827",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -293,14 +328,13 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
abridgedInvoiceTitle: {
|
abridgedInvoiceTitle: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: "bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
color: "#10b981",
|
color: "#10b981",
|
||||||
},
|
},
|
||||||
|
|
||||||
abridgedInvoiceNumber: {
|
abridgedInvoiceNumber: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: "semibold",
|
fontFamily: "Courier-Bold",
|
||||||
fontFamily: "AzeretMono",
|
|
||||||
color: "#111827",
|
color: "#111827",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -320,7 +354,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
tableHeaderCell: {
|
tableHeaderCell: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: "bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
color: "#111827",
|
color: "#111827",
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 4,
|
||||||
},
|
},
|
||||||
@@ -369,8 +403,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
tableCellDate: {
|
tableCellDate: {
|
||||||
width: "15%",
|
width: "15%",
|
||||||
fontFamily: "AzeretMono",
|
fontFamily: "Courier",
|
||||||
fontWeight: "semibold",
|
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -383,24 +416,21 @@ const styles = StyleSheet.create({
|
|||||||
tableCellHours: {
|
tableCellHours: {
|
||||||
width: "12%",
|
width: "12%",
|
||||||
textAlign: "right",
|
textAlign: "right",
|
||||||
fontFamily: "AzeretMono",
|
fontFamily: "Courier",
|
||||||
fontWeight: "semibold",
|
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
},
|
},
|
||||||
|
|
||||||
tableCellRate: {
|
tableCellRate: {
|
||||||
width: "15%",
|
width: "15%",
|
||||||
textAlign: "right",
|
textAlign: "right",
|
||||||
fontFamily: "AzeretMono",
|
fontFamily: "Courier",
|
||||||
fontWeight: "semibold",
|
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
},
|
},
|
||||||
|
|
||||||
tableCellAmount: {
|
tableCellAmount: {
|
||||||
width: "18%",
|
width: "18%",
|
||||||
textAlign: "right",
|
textAlign: "right",
|
||||||
fontFamily: "AzeretMono",
|
fontFamily: "Courier-Bold",
|
||||||
fontWeight: "bold",
|
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -432,9 +462,8 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
totalAmount: {
|
totalAmount: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "AzeretMono",
|
fontFamily: "Courier-Bold",
|
||||||
color: "#111827",
|
color: "#111827",
|
||||||
fontWeight: "semibold",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
finalTotalRow: {
|
finalTotalRow: {
|
||||||
@@ -447,19 +476,19 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
finalTotalLabel: {
|
finalTotalLabel: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: "bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
color: "#1f2937",
|
color: "#1f2937",
|
||||||
},
|
},
|
||||||
|
|
||||||
finalTotalAmount: {
|
finalTotalAmount: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontFamily: "AzeretMono",
|
fontFamily: "Courier-Bold",
|
||||||
fontWeight: "bold",
|
|
||||||
color: "#10b981",
|
color: "#10b981",
|
||||||
},
|
},
|
||||||
|
|
||||||
itemCount: {
|
itemCount: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
|
fontFamily: "Helvetica",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
@@ -757,6 +786,7 @@ const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
|||||||
const Footer: React.FC = () => (
|
const Footer: React.FC = () => (
|
||||||
<View style={styles.footer} fixed>
|
<View style={styles.footer} fixed>
|
||||||
<View style={styles.footerLogo}>
|
<View style={styles.footerLogo}>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||||
<Image
|
<Image
|
||||||
src="/beenvoice-logo.png"
|
src="/beenvoice-logo.png"
|
||||||
style={{
|
style={{
|
||||||
@@ -892,6 +922,9 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
|||||||
// Export functions
|
// Export functions
|
||||||
export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// Ensure fonts are registered
|
||||||
|
registerFonts();
|
||||||
|
|
||||||
// Validate invoice data
|
// Validate invoice data
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
throw new Error("Invoice data is required");
|
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.");
|
throw new Error("Generated PDF is invalid. Please try again.");
|
||||||
} else if (error.message.includes("required")) {
|
} else if (error.message.includes("required")) {
|
||||||
throw new Error(error.message);
|
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(
|
export async function generateInvoicePDFBlob(
|
||||||
invoice: InvoiceData,
|
invoice: InvoiceData,
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
|
const isServerSide = typeof window === "undefined";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Ensure fonts are registered (important for server-side generation)
|
||||||
|
registerFonts();
|
||||||
|
|
||||||
// Validate invoice data
|
// Validate invoice data
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
throw new Error("Invoice data is required");
|
throw new Error("Invoice data is required");
|
||||||
@@ -960,17 +1003,56 @@ export async function generateInvoicePDFBlob(
|
|||||||
throw new Error("Client information is required");
|
throw new Error("Client information is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate PDF blob
|
console.log(
|
||||||
const blob = await pdf(<InvoicePDF invoice={invoice} />).toBlob();
|
`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
|
// Validate blob
|
||||||
if (!blob || blob.size === 0) {
|
if (!blob || blob.size === 0) {
|
||||||
throw new Error("Generated PDF is empty");
|
throw new Error("Generated PDF is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`PDF blob generated successfully, size: ${blob.size} bytes (${isServerSide ? "server-side" : "client-side"})`,
|
||||||
|
);
|
||||||
return blob;
|
return blob;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("PDF generation error:", error);
|
console.error(
|
||||||
throw new Error("Failed to generate PDF");
|
`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
|
* 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" },
|
business: { singular: "Business", plural: "Businesses" },
|
||||||
client: { singular: "Client", plural: "Clients" },
|
client: { singular: "Client", plural: "Clients" },
|
||||||
invoice: { singular: "Invoice", plural: "Invoices" },
|
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)
|
// Check if we have a specific rule for this word (search by plural)
|
||||||
const rule = Object.values(PLURALIZATION_RULES).find(
|
const rule = Object.values(PLURALIZATION_RULES).find(
|
||||||
(r) => r.plural.toLowerCase() === lowerWord
|
(r) => r.plural.toLowerCase() === lowerWord,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (rule) {
|
if (rule) {
|
||||||
@@ -101,7 +104,7 @@ export function capitalize(word: string): string {
|
|||||||
/**
|
/**
|
||||||
* Get a properly formatted label for a route segment
|
* 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
|
// First, check if it's already in our rules
|
||||||
const rule = PLURALIZATION_RULES[segment.toLowerCase()];
|
const rule = PLURALIZATION_RULES[segment.toLowerCase()];
|
||||||
if (rule) {
|
if (rule) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { clientsRouter } from "~/server/api/routers/clients";
|
|||||||
import { businessesRouter } from "~/server/api/routers/businesses";
|
import { businessesRouter } from "~/server/api/routers/businesses";
|
||||||
import { invoicesRouter } from "~/server/api/routers/invoices";
|
import { invoicesRouter } from "~/server/api/routers/invoices";
|
||||||
import { settingsRouter } from "~/server/api/routers/settings";
|
import { settingsRouter } from "~/server/api/routers/settings";
|
||||||
|
import { emailRouter } from "~/server/api/routers/email";
|
||||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
businesses: businessesRouter,
|
businesses: businessesRouter,
|
||||||
invoices: invoicesRouter,
|
invoices: invoicesRouter,
|
||||||
settings: settingsRouter,
|
settings: settingsRouter,
|
||||||
|
email: emailRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -21,6 +21,20 @@ const businessSchema = z.object({
|
|||||||
isDefault: z.boolean().default(false),
|
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({
|
export const businessesRouter = createTRPCRouter({
|
||||||
// Get all businesses for the current user
|
// Get all businesses for the current user
|
||||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||||
@@ -208,4 +222,93 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return updatedBusiness;
|
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"),
|
clientId: z.string().min(1, "Client is required"),
|
||||||
issueDate: z.date(),
|
issueDate: z.date(),
|
||||||
dueDate: 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("")),
|
notes: z.string().optional().or(z.literal("")),
|
||||||
taxRate: z.number().min(0).max(100).default(0),
|
taxRate: z.number().min(0).max(100).default(0),
|
||||||
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
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({
|
const updateStatusSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
status: z.enum(["draft", "sent", "paid", "overdue"]),
|
status: z.enum(["draft", "sent", "paid"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const invoicesRouter = createTRPCRouter({
|
export const invoicesRouter = createTRPCRouter({
|
||||||
@@ -237,7 +237,9 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
const cleanInvoiceData = {
|
const cleanInvoiceData = {
|
||||||
...invoiceData,
|
...invoiceData,
|
||||||
businessId:
|
businessId:
|
||||||
!invoiceData.businessId || invoiceData.businessId.trim() === "" ? null : invoiceData.businessId,
|
!invoiceData.businessId || invoiceData.businessId.trim() === ""
|
||||||
|
? null
|
||||||
|
: invoiceData.businessId,
|
||||||
notes: invoiceData.notes === "" ? null : invoiceData.notes,
|
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 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({
|
const business = await ctx.db.query.businesses.findFirst({
|
||||||
where: eq(businesses.id, cleanInvoiceData.businessId),
|
where: eq(businesses.id, cleanInvoiceData.businessId),
|
||||||
});
|
});
|
||||||
@@ -434,7 +439,10 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
console.log("Status update completed successfully");
|
console.log("Status update completed successfully");
|
||||||
|
|
||||||
return { success: true };
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Invoice status updated to ${input.status}`,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("UpdateStatus error:", error);
|
console.error("UpdateStatus error:", error);
|
||||||
if (error instanceof TRPCError) throw error;
|
if (error instanceof TRPCError) throw error;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
import {
|
import {
|
||||||
@@ -40,7 +40,7 @@ const BusinessBackupSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const InvoiceItemBackupSchema = z.object({
|
const InvoiceItemBackupSchema = z.object({
|
||||||
date: z.date(),
|
date: z.string().transform((str) => new Date(str)),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
hours: z.number(),
|
hours: z.number(),
|
||||||
rate: z.number(),
|
rate: z.number(),
|
||||||
@@ -52,8 +52,8 @@ const InvoiceBackupSchema = z.object({
|
|||||||
invoiceNumber: z.string(),
|
invoiceNumber: z.string(),
|
||||||
businessName: z.string().optional(),
|
businessName: z.string().optional(),
|
||||||
clientName: z.string(),
|
clientName: z.string(),
|
||||||
issueDate: z.date(),
|
issueDate: z.string().transform((str) => new Date(str)),
|
||||||
dueDate: z.date(),
|
dueDate: z.string().transform((str) => new Date(str)),
|
||||||
status: z.string().default("draft"),
|
status: z.string().default("draft"),
|
||||||
totalAmount: z.number().default(0),
|
totalAmount: z.number().default(0),
|
||||||
taxRate: 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");
|
throw new Error("User not found or no password set");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,13 +47,16 @@ export const authConfig = {
|
|||||||
name: "credentials",
|
name: "credentials",
|
||||||
credentials: {
|
credentials: {
|
||||||
email: { label: "Email", type: "email" },
|
email: { label: "Email", type: "email" },
|
||||||
password: { label: "Password", type: "password" }
|
password: { label: "Password", type: "password" },
|
||||||
},
|
},
|
||||||
async authorize(credentials) {
|
async authorize(credentials) {
|
||||||
if (!credentials?.email || !credentials?.password) {
|
if (!credentials?.email || !credentials?.password) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof credentials.email !== 'string' || typeof credentials.password !== 'string') {
|
if (
|
||||||
|
typeof credentials.email !== "string" ||
|
||||||
|
typeof credentials.password !== "string"
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,11 +64,14 @@ export const authConfig = {
|
|||||||
where: eq(users.email, credentials.email),
|
where: eq(users.email, credentials.email),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.password) {
|
if (!user?.password) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPasswordValid = await bcrypt.compare(credentials.password, user.password);
|
const isPasswordValid = await bcrypt.compare(
|
||||||
|
credentials.password,
|
||||||
|
user.password,
|
||||||
|
);
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
return null;
|
return null;
|
||||||
@@ -76,7 +82,7 @@ export const authConfig = {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
adapter: DrizzleAdapter(db, {
|
adapter: DrizzleAdapter(db, {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createClient, type Client } from "@libsql/client";
|
import { Pool } from "pg";
|
||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
@@ -9,15 +9,18 @@ import * as schema from "./schema";
|
|||||||
* update.
|
* update.
|
||||||
*/
|
*/
|
||||||
const globalForDb = globalThis as unknown as {
|
const globalForDb = globalThis as unknown as {
|
||||||
client: Client | undefined;
|
pool: Pool | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const client =
|
export const pool =
|
||||||
globalForDb.client ??
|
globalForDb.pool ??
|
||||||
createClient({
|
new Pool({
|
||||||
url: env.DATABASE_URL,
|
connectionString: env.DATABASE_URL,
|
||||||
authToken: env.DATABASE_AUTH_TOKEN,
|
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 { 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";
|
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
|
* @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)
|
// Auth-related tables (keeping existing)
|
||||||
export const users = createTable("user", (d) => ({
|
export const users = createTable("user", (d) => ({
|
||||||
id: d
|
id: d
|
||||||
.text({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => crypto.randomUUID()),
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
name: d.text({ length: 255 }),
|
name: d.varchar({ length: 255 }),
|
||||||
email: d.text({ length: 255 }).notNull(),
|
email: d.varchar({ length: 255 }).notNull(),
|
||||||
password: d.text({ length: 255 }),
|
password: d.varchar({ length: 255 }),
|
||||||
emailVerified: d.integer({ mode: "timestamp" }).default(sql`(unixepoch())`),
|
emailVerified: d.timestamp().default(sql`CURRENT_TIMESTAMP`),
|
||||||
image: d.text({ length: 255 }),
|
image: d.varchar({ length: 255 }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
@@ -35,19 +35,19 @@ export const accounts = createTable(
|
|||||||
"account",
|
"account",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
userId: d
|
userId: d
|
||||||
.text({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
type: d.text({ length: 255 }).$type<AdapterAccount["type"]>().notNull(),
|
type: d.varchar({ length: 255 }).$type<AdapterAccount["type"]>().notNull(),
|
||||||
provider: d.text({ length: 255 }).notNull(),
|
provider: d.varchar({ length: 255 }).notNull(),
|
||||||
providerAccountId: d.text({ length: 255 }).notNull(),
|
providerAccountId: d.varchar({ length: 255 }).notNull(),
|
||||||
refresh_token: d.text(),
|
refresh_token: d.text(),
|
||||||
access_token: d.text(),
|
access_token: d.text(),
|
||||||
expires_at: d.integer(),
|
expires_at: d.integer(),
|
||||||
token_type: d.text({ length: 255 }),
|
token_type: d.varchar({ length: 255 }),
|
||||||
scope: d.text({ length: 255 }),
|
scope: d.varchar({ length: 255 }),
|
||||||
id_token: d.text(),
|
id_token: d.text(),
|
||||||
session_state: d.text({ length: 255 }),
|
session_state: d.varchar({ length: 255 }),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [
|
||||||
primaryKey({
|
primaryKey({
|
||||||
@@ -64,12 +64,12 @@ export const accountsRelations = relations(accounts, ({ one }) => ({
|
|||||||
export const sessions = createTable(
|
export const sessions = createTable(
|
||||||
"session",
|
"session",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
sessionToken: d.text({ length: 255 }).notNull().primaryKey(),
|
sessionToken: d.varchar({ length: 255 }).notNull().primaryKey(),
|
||||||
userId: d
|
userId: d
|
||||||
.text({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
expires: d.integer({ mode: "timestamp" }).notNull(),
|
expires: d.timestamp().notNull(),
|
||||||
}),
|
}),
|
||||||
(t) => [index("session_userId_idx").on(t.userId)],
|
(t) => [index("session_userId_idx").on(t.userId)],
|
||||||
);
|
);
|
||||||
@@ -81,9 +81,9 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({
|
|||||||
export const verificationTokens = createTable(
|
export const verificationTokens = createTable(
|
||||||
"verification_token",
|
"verification_token",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
identifier: d.text({ length: 255 }).notNull(),
|
identifier: d.varchar({ length: 255 }).notNull(),
|
||||||
token: d.text({ length: 255 }).notNull(),
|
token: d.varchar({ length: 255 }).notNull(),
|
||||||
expires: d.integer({ mode: "timestamp" }).notNull(),
|
expires: d.timestamp().notNull(),
|
||||||
}),
|
}),
|
||||||
(t) => [primaryKey({ columns: [t.identifier, t.token] })],
|
(t) => [primaryKey({ columns: [t.identifier, t.token] })],
|
||||||
);
|
);
|
||||||
@@ -93,29 +93,29 @@ export const clients = createTable(
|
|||||||
"client",
|
"client",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
id: d
|
id: d
|
||||||
.text({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => crypto.randomUUID()),
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
name: d.text({ length: 255 }).notNull(),
|
name: d.varchar({ length: 255 }).notNull(),
|
||||||
email: d.text({ length: 255 }),
|
email: d.varchar({ length: 255 }),
|
||||||
phone: d.text({ length: 50 }),
|
phone: d.varchar({ length: 50 }),
|
||||||
addressLine1: d.text({ length: 255 }),
|
addressLine1: d.varchar({ length: 255 }),
|
||||||
addressLine2: d.text({ length: 255 }),
|
addressLine2: d.varchar({ length: 255 }),
|
||||||
city: d.text({ length: 100 }),
|
city: d.varchar({ length: 100 }),
|
||||||
state: d.text({ length: 50 }),
|
state: d.varchar({ length: 50 }),
|
||||||
postalCode: d.text({ length: 20 }),
|
postalCode: d.varchar({ length: 20 }),
|
||||||
country: d.text({ length: 100 }),
|
country: d.varchar({ length: 100 }),
|
||||||
defaultHourlyRate: d.real().notNull().default(100.0),
|
defaultHourlyRate: d.real().notNull().default(100.0),
|
||||||
createdById: d
|
createdById: d
|
||||||
.text({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.integer({ mode: "timestamp" })
|
.timestamp()
|
||||||
.default(sql`(unixepoch())`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
|
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [
|
||||||
index("client_created_by_idx").on(t.createdById),
|
index("client_created_by_idx").on(t.createdById),
|
||||||
@@ -136,32 +136,36 @@ export const businesses = createTable(
|
|||||||
"business",
|
"business",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
id: d
|
id: d
|
||||||
.text({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => crypto.randomUUID()),
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
name: d.text({ length: 255 }).notNull(),
|
name: d.varchar({ length: 255 }).notNull(),
|
||||||
email: d.text({ length: 255 }),
|
email: d.varchar({ length: 255 }),
|
||||||
phone: d.text({ length: 50 }),
|
phone: d.varchar({ length: 50 }),
|
||||||
addressLine1: d.text({ length: 255 }),
|
addressLine1: d.varchar({ length: 255 }),
|
||||||
addressLine2: d.text({ length: 255 }),
|
addressLine2: d.varchar({ length: 255 }),
|
||||||
city: d.text({ length: 100 }),
|
city: d.varchar({ length: 100 }),
|
||||||
state: d.text({ length: 50 }),
|
state: d.varchar({ length: 50 }),
|
||||||
postalCode: d.text({ length: 20 }),
|
postalCode: d.varchar({ length: 20 }),
|
||||||
country: d.text({ length: 100 }),
|
country: d.varchar({ length: 100 }),
|
||||||
website: d.text({ length: 255 }),
|
website: d.varchar({ length: 255 }),
|
||||||
taxId: d.text({ length: 100 }),
|
taxId: d.varchar({ length: 100 }),
|
||||||
logoUrl: d.text({ length: 500 }),
|
logoUrl: d.varchar({ length: 500 }),
|
||||||
isDefault: d.integer({ mode: "boolean" }).default(false),
|
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
|
createdById: d
|
||||||
.text({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.integer({ mode: "timestamp" })
|
.timestamp()
|
||||||
.default(sql`(unixepoch())`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
|
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [
|
||||||
index("business_created_by_idx").on(t.createdById),
|
index("business_created_by_idx").on(t.createdById),
|
||||||
@@ -183,31 +187,31 @@ export const invoices = createTable(
|
|||||||
"invoice",
|
"invoice",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
id: d
|
id: d
|
||||||
.text({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => crypto.randomUUID()),
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
invoiceNumber: d.text({ length: 100 }).notNull(),
|
invoiceNumber: d.varchar({ length: 100 }).notNull(),
|
||||||
businessId: d.text({ length: 255 }).references(() => businesses.id),
|
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
|
||||||
clientId: d
|
clientId: d
|
||||||
.text({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => clients.id),
|
.references(() => clients.id),
|
||||||
issueDate: d.integer({ mode: "timestamp" }).notNull(),
|
issueDate: d.timestamp().notNull(),
|
||||||
dueDate: d.integer({ mode: "timestamp" }).notNull(),
|
dueDate: d.timestamp().notNull(),
|
||||||
status: d.text({ length: 50 }).notNull().default("draft"), // draft, sent, paid, overdue
|
status: d.varchar({ length: 50 }).notNull().default("draft"), // draft, sent, paid (overdue computed)
|
||||||
totalAmount: d.real().notNull().default(0),
|
totalAmount: d.real().notNull().default(0),
|
||||||
taxRate: d.real().notNull().default(0.0),
|
taxRate: d.real().notNull().default(0.0),
|
||||||
notes: d.text({ length: 1000 }),
|
notes: d.varchar({ length: 1000 }),
|
||||||
createdById: d
|
createdById: d
|
||||||
.text({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.integer({ mode: "timestamp" })
|
.timestamp()
|
||||||
.default(sql`(unixepoch())`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updatedAt: d.integer({ mode: "timestamp" }).$onUpdate(() => new Date()),
|
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [
|
||||||
index("invoice_business_id_idx").on(t.businessId),
|
index("invoice_business_id_idx").on(t.businessId),
|
||||||
@@ -238,23 +242,23 @@ export const invoiceItems = createTable(
|
|||||||
"invoice_item",
|
"invoice_item",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
id: d
|
id: d
|
||||||
.text({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => crypto.randomUUID()),
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
invoiceId: d
|
invoiceId: d
|
||||||
.text({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => invoices.id, { onDelete: "cascade" }),
|
.references(() => invoices.id, { onDelete: "cascade" }),
|
||||||
date: d.integer({ mode: "timestamp" }).notNull(),
|
date: d.timestamp().notNull(),
|
||||||
description: d.text({ length: 500 }).notNull(),
|
description: d.varchar({ length: 500 }).notNull(),
|
||||||
hours: d.real().notNull(),
|
hours: d.real().notNull(),
|
||||||
rate: d.real().notNull(),
|
rate: d.real().notNull(),
|
||||||
amount: d.real().notNull(),
|
amount: d.real().notNull(),
|
||||||
position: d.integer().notNull().default(0), // NEW: position for ordering
|
position: d.integer().notNull().default(0), // NEW: position for ordering
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.integer({ mode: "timestamp" })
|
.timestamp()
|
||||||
.default(sql`(unixepoch())`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(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