7 Commits

Author SHA1 Message Date
soconnor 0e46fdafb2 feat: add administration page and account role management
- Implemented `AdministrationContent` component for managing account roles.
- Created `AdministrationPage` to serve as the main entry point for administration tasks.
- Added PDF preview functionality with `PdfPreviewFrame` component for invoice generation.
- Introduced `InputColor` component for advanced color selection with various formats.
- Established color conversion utilities in `color-converter.ts` for handling color formats.
- Defined appearance-related schemas and types in `appearance.ts` for consistent theme management.
2026-04-30 10:50:50 -04:00
soconnor ddc2b42672 Refactor invoice data table and templates page for improved readability and functionality
- Cleaned up imports and formatted code for better readability in invoices-data-table.tsx.
- Enhanced invoice interface definitions for clarity.
- Improved toast messages for bulk delete and update actions.
- Refactored date formatting and status type retrieval for better readability.
- Simplified template management in templates page, extracting TemplateList component.
- Added registration toggle based on environment variable DISABLE_SIGNUPS.
- Updated navbar to conditionally render registration link based on allowRegistration prop.
- Enhanced error handling and validation in expenses and settings routers.
- Improved PDF export footer handling.
- Updated TRPC react integration for cleaner type imports.
2026-04-29 22:49:07 -04:00
soconnor dbb739b060 refactor: update SendEmailPage layout and remove SendEmailDialog component 2026-04-28 01:30:38 -04:00
soconnor bd3181fb9d feat: add PDF preview functionality and normalize email message handling 2026-04-28 01:26:47 -04:00
soconnor 915ec103fc feat: add email message field to invoices and update related components 2026-04-28 01:06:45 -04:00
soconnor 4108019eab feat: enhance PDF generation with improved line estimation and page budgeting 2026-04-28 00:44:00 -04:00
soconnor 84a5d997b4 refactor: remove InvoiceView component and update related email and invoice handling
- Deleted the InvoiceView component to streamline the codebase.
- Updated EmailPreview and SendEmailDialog components to include currency and notes fields.
- Enhanced invoice-form to handle default hourly rates and improved item mapping.
- Refactored email template generation to include notes and currency formatting.
- Adjusted API routers for invoices to calculate totals and handle notes and currency correctly.
2026-04-28 00:34:56 -04:00
117 changed files with 6698 additions and 4652 deletions
+38 -4
View File
@@ -44,22 +44,26 @@ A modern, professional invoicing application built for freelancers and small bus
### Quick Start ### Quick Start
1. **Clone the repository** 1. **Clone the repository**
```bash ```bash
git clone https://github.com/yourusername/beenvoice.git git clone https://github.com/yourusername/beenvoice.git
cd beenvoice cd beenvoice
``` ```
2. **Install dependencies** 2. **Install dependencies**
```bash ```bash
bun install bun install
``` ```
3. **Set up environment variables** 3. **Set up environment variables**
```bash ```bash
cp .env.example .env.local cp .env.example .env.local
``` ```
Edit `.env.local` and add your configuration: Edit `.env.local` and add your configuration:
```env ```env
# Database # Database
DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice" DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice"
@@ -78,17 +82,20 @@ A modern, professional invoicing application built for freelancers and small bus
RESEND_DOMAIN="yourdomain.com" RESEND_DOMAIN="yourdomain.com"
``` ```
4. **Start the database** 4. **Start the development database**
```bash ```bash
docker-compose up -d docker compose -f docker-compose.dev.yml up -d db
``` ```
5. **Push the database schema** 5. **Push the database schema**
```bash ```bash
bun run db:push bun run db:push
``` ```
6. **Start the development server** 6. **Start the development server**
```bash ```bash
bun run dev bun run dev
``` ```
@@ -123,7 +130,8 @@ beenvoice/
├── drizzle/ # Database migrations ├── drizzle/ # Database migrations
├── public/ # Static assets ├── public/ # Static assets
├── docs/ # Documentation ├── docs/ # Documentation
── docker-compose.yml # Local PostgreSQL setup ── docker-compose.yml # Deployment compose stack
└── docker-compose.dev.yml # Development overrides with exposed PostgreSQL
``` ```
## 🎯 Usage ## 🎯 Usage
@@ -155,12 +163,14 @@ beenvoice/
### Features Overview ### Features Overview
#### Client Management #### Client Management
- Create and edit client profiles - Create and edit client profiles
- Store contact information and addresses - Store contact information and addresses
- Set default hourly rates per client - Set default hourly rates per client
- Search and filter client list - Search and filter client list
#### Invoice Creation #### Invoice Creation
- Select from existing clients and business profiles - Select from existing clients and business profiles
- Add multiple line items with drag-and-drop reordering - Add multiple line items with drag-and-drop reordering
- Set custom rates per item - Set custom rates per item
@@ -169,12 +179,14 @@ beenvoice/
- Professional invoice formatting - Professional invoice formatting
#### Invoice Delivery #### Invoice Delivery
- Send invoices via email directly from the app - Send invoices via email directly from the app
- Rich text email composer with preview - Rich text email composer with preview
- Resend and re-deliver sent invoices - Resend and re-deliver sent invoices
- Track invoice status: Draft → Sent → Paid (+ Overdue) - Track invoice status: Draft → Sent → Paid (+ Overdue)
#### User Interface #### User Interface
- Clean, modern design - Clean, modern design
- Fully responsive — desktop, tablet, and mobile - Fully responsive — desktop, tablet, and mobile
- Intuitive navigation with breadcrumbs - Intuitive navigation with breadcrumbs
@@ -198,7 +210,8 @@ bun run db:studio # Open Drizzle Studio
bun run db:generate # Generate new migration bun run db:generate # Generate new migration
# Docker # Docker
bun run docker:up # Start local PostgreSQL via Docker bun run docker:up # Start deployment compose stack
bun run docker:dev:up # Start development compose stack with exposed PostgreSQL
bun run docker:down # Stop Docker services bun run docker:down # Stop Docker services
# Code Quality # Code Quality
@@ -208,6 +221,24 @@ bun run format:write # Format code with Prettier
bun run typecheck # Run TypeScript type checking bun run typecheck # Run TypeScript type checking
``` ```
### Docker Compose
Use the base compose file for deployment. It keeps PostgreSQL internal to the
compose network:
```bash
docker compose up -d
```
For local development, use the dev compose file to expose PostgreSQL on
`${POSTGRES_PORT:-5432}`:
```bash
docker compose -f docker-compose.dev.yml up -d
```
Set `DISABLE_SIGNUPS=true` to block new email/password account registration.
### Database Schema ### Database Schema
The application uses the following core tables: The application uses the following core tables:
@@ -243,6 +274,7 @@ The app uses Tailwind CSS v4 with a custom design system:
### Branding ### Branding
Update the logo and colors in: Update the logo and colors in:
- `src/components/logo.tsx` - Main logo component - `src/components/logo.tsx` - Main logo component
- `src/styles/globals.css` - Color variables - `src/styles/globals.css` - Color variables
- `src/app/layout.tsx` - Font configuration - `src/app/layout.tsx` - Font configuration
@@ -252,6 +284,7 @@ Update the logo and colors in:
You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.). You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.).
1. **Build the application:** 1. **Build the application:**
```bash ```bash
bun run build bun run build
``` ```
@@ -259,6 +292,7 @@ You can deploy this application to any platform that supports Next.js and Postgr
2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production) 2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production)
3. **Run database migrations:** 3. **Run database migrations:**
```bash ```bash
bun run db:push bun run db:push
``` ```
+20 -7
View File
@@ -10,6 +10,7 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/playfair-display": "^5.2.8",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -50,11 +51,12 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "^16.2.2", "next": "^16.2.4",
"pg": "8.13.1", "pg": "8.13.1",
"react": "^19.2.4", "react": "^19.2.5",
"react-colorful": "^5.6.1",
"react-day-picker": "^9.12.0", "react-day-picker": "^9.12.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.5",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"recharts": "^3.5.1", "recharts": "^3.5.1",
"resend": "^4.8.0", "resend": "^4.8.0",
@@ -71,12 +73,13 @@
"@types/node": "^20.19.26", "@types/node": "^20.19.26",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/raf": "^3.4.3", "@types/raf": "^3.4.3",
"@types/react": "^19.2.7", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"baseline-browser-mapping": "^2.9.6", "babel-plugin-react-compiler": "^1.0.0",
"baseline-browser-mapping": "^2.10.24",
"drizzle-kit": "^0.30.6", "drizzle-kit": "^0.30.6",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^16.0.10", "eslint-config-next": "^16.2.4",
"eslint-plugin-drizzle": "^0.2.3", "eslint-plugin-drizzle": "^0.2.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "3.6.2", "prettier": "3.6.2",
@@ -250,6 +253,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@fontsource-variable/playfair-display": ["@fontsource-variable/playfair-display@5.2.8", "", {}, "sha512-ZzVIXPOrL85yyOvZYoBzUszIJM+xKkHqni4IYn2CVLaGQQdJR8sBeC8yFNgjxSJ7ludTwta8qpULeOFuk5X75A=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
@@ -750,11 +755,13 @@
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.24", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA=="],
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
@@ -1376,6 +1383,8 @@
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-colorful": ["react-colorful@5.6.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="],
"react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="],
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
@@ -1692,6 +1701,8 @@
"brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="],
"color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -1714,6 +1725,8 @@
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"next/baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+21
View File
@@ -0,0 +1,21 @@
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes:
- beenvoice_dev_pg_data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s
timeout: 5s
retries: 10
ports:
- "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped
volumes:
beenvoice_dev_pg_data:
+3 -1
View File
@@ -15,6 +15,7 @@ services:
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-} NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js} NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false} NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-} AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-} AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-} AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
@@ -35,7 +36,8 @@ services:
volumes: volumes:
- beenvoice_pg_data:/var/lib/postgresql/data - beenvoice_pg_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "beenvoice_invoice"
ADD COLUMN "emailMessage" varchar(2000);
+7
View File
@@ -50,6 +50,13 @@
"when": 1777338000000, "when": 1777338000000,
"tag": "0006_pdf_generation_settings", "tag": "0006_pdf_generation_settings",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1777339000000,
"tag": "0007_invoice_email_message",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -7,6 +7,7 @@ import "./src/env.js";
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
output: "standalone", output: "standalone",
reactCompiler: true,
serverExternalPackages: ["pg"], serverExternalPackages: ["pg"],
}; };
+12 -8
View File
@@ -11,8 +11,9 @@
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:clone": "./scripts/clone-local.sh", "db:clone": "./scripts/clone-local.sh",
"docker:up": "colima start && docker compose up -d", "docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
"docker:down": "docker compose down && colima stop", "docker:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
"deploy": "drizzle-kit push && next build", "deploy": "drizzle-kit push && next build",
"dev": "next dev --turbo", "dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
@@ -29,6 +30,7 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/playfair-display": "^5.2.8",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -69,11 +71,12 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "^16.2.2", "next": "^16.2.4",
"pg": "8.13.1", "pg": "8.13.1",
"react": "^19.2.4", "react": "^19.2.5",
"react-colorful": "^5.6.1",
"react-day-picker": "^9.12.0", "react-day-picker": "^9.12.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.5",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"recharts": "^3.5.1", "recharts": "^3.5.1",
"resend": "^4.8.0", "resend": "^4.8.0",
@@ -90,12 +93,13 @@
"@types/node": "^20.19.26", "@types/node": "^20.19.26",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/raf": "^3.4.3", "@types/raf": "^3.4.3",
"@types/react": "^19.2.7", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"baseline-browser-mapping": "^2.9.6", "babel-plugin-react-compiler": "^1.0.0",
"baseline-browser-mapping": "^2.10.24",
"drizzle-kit": "^0.30.6", "drizzle-kit": "^0.30.6",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^16.0.10", "eslint-config-next": "^16.2.4",
"eslint-plugin-drizzle": "^0.2.3", "eslint-plugin-drizzle": "^0.2.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "3.6.2", "prettier": "3.6.2",
+4 -3
View File
@@ -35,9 +35,10 @@ export default function TermsOfServicePage() {
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <CardContent className="prose prose-sm max-w-none">
<p> <p>
These Terms of Service (&quot;Terms&quot;) govern your use of the These Terms of Service (&quot;Terms&quot;) govern your use of
beenvoice platform and services (the &quot;Service&quot;) operated by the beenvoice platform and services (the &quot;Service&quot;)
beenvoice (&quot;us&quot;, &quot;we&quot;, or &quot;our&quot;). operated by beenvoice (&quot;us&quot;, &quot;we&quot;, or
&quot;our&quot;).
</p> </p>
<p> <p>
By accessing or using our Service, you agree to be bound by By accessing or using our Service, you agree to be bound by
+52 -14
View File
@@ -2,59 +2,97 @@ import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { env } from "~/env";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
const registerSchema = z.object({ const registerSchema = z.object({
firstName: z.string().min(1, "First name is required"), firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"), lastName: z.string().trim().min(1, "Last name is required"),
email: z.string().email("Invalid email address"), email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"), password: z.string().min(8, "Password must be at least 8 characters"),
}); });
const fieldLabels: Record<string, string> = {
firstName: "First name",
lastName: "Last name",
email: "Email address",
password: "Password",
};
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() as z.infer<typeof registerSchema>; if (env.DISABLE_SIGNUPS === true) {
return NextResponse.json(
{ error: "New account registration is currently disabled" },
{ status: 403 },
);
}
const body = (await request.json()) as unknown;
const { firstName, lastName, email, password } = registerSchema.parse(body); const { firstName, lastName, email, password } = registerSchema.parse(body);
const normalizedEmail = email.toLowerCase();
// Check if user already exists // Check if user already exists
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.email, email), where: eq(users.email, normalizedEmail),
}); });
if (existingUser) { if (existingUser) {
return NextResponse.json( return NextResponse.json(
{ error: "User with this email already exists" }, { error: "User with this email already exists" },
{ status: 400 } { status: 400 },
); );
} }
// Hash password // Hash password
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
// Create user await db.transaction(async (tx) => {
await db.insert(users).values({ const [user] = await tx
.insert(users)
.values({
name: `${firstName} ${lastName}`, name: `${firstName} ${lastName}`,
email, email: normalizedEmail,
password: hashedPassword, password: hashedPassword,
})
.returning({ id: users.id });
if (!user) {
throw new Error("Failed to create user");
}
await tx.insert(accounts).values({
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
});
}); });
return NextResponse.json( return NextResponse.json(
{ message: "User created successfully" }, { message: "User created successfully" },
{ status: 201 } { status: 201 },
); );
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
const issue = error.errors[0];
const field = issue?.path[0];
const fallback =
typeof field === "string"
? `${fieldLabels[field] ?? field} is required`
: "Please check the registration form";
return NextResponse.json( return NextResponse.json(
{ error: error.errors[0]?.message ?? "Validation error" }, { error: issue?.message === "Required" ? fallback : issue?.message },
{ status: 400 } { status: 400 },
); );
} }
console.error("Registration error:", error); console.error("Registration error:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },
{ status: 500 } { status: 500 },
); );
} }
} }
+28 -3
View File
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm"; import { eq, and, gt } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -47,8 +47,8 @@ export async function POST(request: NextRequest) {
// Hash the new password // Hash the new password
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
// Update user with new password and clear reset token await db.transaction(async (tx) => {
await db await tx
.update(users) .update(users)
.set({ .set({
password: hashedPassword, password: hashedPassword,
@@ -57,6 +57,31 @@ export async function POST(request: NextRequest) {
}) })
.where(eq(users.id, user.id)); .where(eq(users.id, user.id));
const credentialAccount = await tx.query.accounts.findFirst({
where: and(
eq(accounts.userId, user.id),
eq(accounts.providerId, "credential"),
),
});
if (credentialAccount) {
await tx
.update(accounts)
.set({
password: hashedPassword,
updatedAt: new Date(),
})
.where(eq(accounts.id, credentialAccount.id));
} else {
await tx.insert(accounts).values({
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
});
}
});
return NextResponse.json( return NextResponse.json(
{ {
success: true, success: true,
+2 -1
View File
@@ -28,7 +28,8 @@ function RegisterForm() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
name: `${firstName} ${lastName}`, firstName,
lastName,
email, email,
password, password,
}), }),
+3 -2
View File
@@ -29,11 +29,12 @@ function ResetPasswordForm() {
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [tokenValid, setTokenValid] = useState<boolean | null>(null); const [tokenValid, setTokenValid] = useState<boolean | null>(() =>
token ? null : false,
);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
setTokenValid(false);
return; return;
} }
+3 -282
View File
@@ -1,290 +1,11 @@
"use client"; import { Suspense } from "react";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { authClient } from "~/lib/auth-client";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalModal } from "~/components/ui/legal-modal";
import { env } from "~/env"; import { env } from "~/env";
import { import { SignInForm } from "./signin-form";
Mail,
Lock,
ArrowRight,
Users,
FileText,
TrendingUp,
Shield,
} from "lucide-react";
function SignInForm() {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const { error } = await authClient.signIn.email({
email,
password,
});
setLoading(false);
if (error) {
toast.error(error.message ?? "Invalid email or password");
} else {
toast.success("Signed in successfully!");
router.push(callbackUrl);
router.refresh();
}
}
async function handleSocialSignIn() {
setLoading(true);
try {
await authClient.signIn.oauth2({
providerId: "authentik",
callbackURL: callbackUrl,
});
// The signIn.sso method will automatically redirect to the SSO provider
} catch (error) {
console.error("[SSO Error]", error);
setLoading(false);
}
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
{/* Blob Background */}
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div>
<Card className="md:bg-background/80 md:border-border/50 mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:rounded-3xl md:border md:shadow-2xl md:backdrop-blur-xl">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-primary/5 border-border/50 relative hidden border-r md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="font-heading text-3xl font-bold lg:text-4xl">
Welcome back to your
<span className="text-primary italic">
{" "}
invoicing workspace
</span>
</h1>
<p className="text-muted-foreground text-lg">
Continue managing your clients and creating professional
invoices that get you paid faster.
</p>
</div>
</div>
<div className="grid gap-6">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<Users className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Client Management
</h3>
<p className="text-muted-foreground text-sm">
Organize and track all your clients in one place
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<FileText className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Professional Invoices
</h3>
<p className="text-muted-foreground text-sm">
Beautiful templates that get you paid faster
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<TrendingUp className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Payment Tracking
</h3>
<p className="text-muted-foreground text-sm">
Monitor your income with real-time insights
</p>
</div>
</div>
</div>
</div>
</div>
{/* Sign In Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center md:text-left">
<h1 className="font-heading text-3xl font-bold">Sign In</h1>
<p className="text-muted-foreground">
Enter your credentials to access your account
</p>
</div>
{authentikEnabled && (
<div className="space-y-4">
<Button
variant="outline"
type="button"
className="relative h-11 w-full rounded-xl"
onClick={handleSocialSignIn}
disabled={loading}
>
<Shield className="mr-2 h-4 w-4" />
Sign in with Authentik
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="border-border/50 w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
Or continue with
</span>
</div>
</div>
</div>
)}
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="m@example.com"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password"
className="text-primary text-sm hover:underline"
>
Forgot password?
</a>
</div>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="Enter your password"
/>
</div>
</div>
<Button
type="submit"
className="shadow-primary/20 hover:shadow-primary/30 h-11 w-full rounded-xl text-base shadow-lg"
disabled={loading}
>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
<span>Signing in...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Sign In</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a
href="/auth/register"
className="text-primary font-medium hover:underline"
>
Sign up
</a>
</div>
<div className="text-muted-foreground text-center text-xs leading-relaxed">
By signing in, you agree to our{" "}
<LegalModal
type="terms"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Terms of Service
</span>
}
/>{" "}
and{" "}
<LegalModal
type="privacy"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Privacy Policy
</span>
}
/>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function SignInPage() { export default function SignInPage() {
return ( return (
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
<SignInForm /> <SignInForm allowRegistration={env.DISABLE_SIGNUPS !== true} />
</Suspense> </Suspense>
); );
} }
+303
View File
@@ -0,0 +1,303 @@
"use client";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { authClient } from "~/lib/auth-client";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalModal } from "~/components/ui/legal-modal";
import { env } from "~/env";
import {
Mail,
Lock,
ArrowRight,
Users,
FileText,
TrendingUp,
Shield,
} from "lucide-react";
interface SignInFormProps {
allowRegistration: boolean;
}
export function SignInForm({ allowRegistration }: SignInFormProps) {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const signupDisabled = searchParams.get("signup") === "disabled";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const { error } = await authClient.signIn.email({
email,
password,
});
setLoading(false);
if (error) {
toast.error(error.message ?? "Invalid email or password");
} else {
toast.success("Signed in successfully!");
router.push(callbackUrl);
router.refresh();
}
}
async function handleSocialSignIn() {
setLoading(true);
try {
await authClient.signIn.oauth2({
providerId: "authentik",
callbackURL: callbackUrl,
});
// The signIn.sso method will automatically redirect to the SSO provider
} catch (error) {
console.error("[SSO Error]", error);
setLoading(false);
}
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
{/* Blob Background */}
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div>
<Card className="md:bg-background/80 md:border-border/50 mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:rounded-3xl md:border md:shadow-2xl md:backdrop-blur-xl">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-primary/5 border-border/50 relative hidden border-r md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="font-heading text-3xl font-bold lg:text-4xl">
Welcome back to your
<span className="text-primary italic">
{" "}
invoicing workspace
</span>
</h1>
<p className="text-muted-foreground text-lg">
Continue managing your clients and creating professional
invoices that get you paid faster.
</p>
</div>
</div>
<div className="grid gap-6">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<Users className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Client Management
</h3>
<p className="text-muted-foreground text-sm">
Organize and track all your clients in one place
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<FileText className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Professional Invoices
</h3>
<p className="text-muted-foreground text-sm">
Beautiful templates that get you paid faster
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<TrendingUp className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Payment Tracking
</h3>
<p className="text-muted-foreground text-sm">
Monitor your income with real-time insights
</p>
</div>
</div>
</div>
</div>
</div>
{/* Sign In Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center md:text-left">
<h1 className="font-heading text-3xl font-bold">Sign In</h1>
<p className="text-muted-foreground">
Enter your credentials to access your account
</p>
</div>
{signupDisabled && (
<div className="border-border bg-muted/50 text-muted-foreground rounded-lg border px-3 py-2 text-sm">
New account registration is currently disabled.
</div>
)}
{authentikEnabled && (
<div className="space-y-4">
<Button
variant="outline"
type="button"
className="relative h-11 w-full rounded-xl"
onClick={handleSocialSignIn}
disabled={loading}
>
<Shield className="mr-2 h-4 w-4" />
Sign in with Authentik
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="border-border/50 w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
Or continue with
</span>
</div>
</div>
</div>
)}
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="m@example.com"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password"
className="text-primary text-sm hover:underline"
>
Forgot password?
</a>
</div>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="Enter your password"
/>
</div>
</div>
<Button
type="submit"
className="shadow-primary/20 hover:shadow-primary/30 h-11 w-full rounded-xl text-base shadow-lg"
disabled={loading}
>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
<span>Signing in...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Sign In</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
{allowRegistration && (
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a
href="/auth/register"
className="text-primary font-medium hover:underline"
>
Sign up
</a>
</div>
)}
<div className="text-muted-foreground text-center text-xs leading-relaxed">
By signing in, you agree to our{" "}
<LegalModal
type="terms"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Terms of Service
</span>
}
/>{" "}
and{" "}
<LegalModal
type="privacy"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Privacy Policy
</span>
}
/>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function SignInPageClient() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SignInForm allowRegistration />
</Suspense>
);
}
@@ -16,6 +16,47 @@ interface InvoiceStatusChartProps {
invoices: Invoice[]; invoices: Invoice[];
} }
const STATUS_COLORS = {
draft: "hsl(0, 0%, 60%)",
sent: "hsl(217, 91%, 60%)",
pending: "hsl(217, 91%, 60%)",
paid: "hsl(142, 71%, 45%)",
overdue: "hsl(var(--destructive))",
} as const;
const formatChartCurrency = (value: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
function StatusTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{
payload: { name: string; count: number; value: number };
}>;
}) {
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{data.name}</p>
<p className="text-sm">
{data.count} invoice{data.count !== 1 ? "s" : ""}
</p>
<p className="text-sm">{formatChartCurrency(data.value)}</p>
</div>
);
}
return null;
}
export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) { export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
// Process invoice data to create status breakdown // Process invoice data to create status breakdown
const statusData = invoices.reduce( const statusData = invoices.reduce(
@@ -44,14 +85,6 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
name: item.status.charAt(0).toUpperCase() + item.status.slice(1), name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
})); }));
// Use theme-aware colors
const COLORS = {
draft: "hsl(0, 0%, 60%)", // neutral grey - matches monthly metrics chart
sent: "hsl(217, 91%, 60%)", // vibrant blue
pending: "hsl(217, 91%, 60%)", // blue
paid: "hsl(142, 71%, 45%)", // vibrant green
overdue: "hsl(var(--destructive))", // red
};
// Animation / motion preferences // Animation / motion preferences
const { prefersReducedMotion, animationSpeedMultiplier } = const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences(); useAnimationPreferences();
@@ -59,39 +92,6 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
600 / (animationSpeedMultiplier || 1), 600 / (animationSpeedMultiplier || 1),
); );
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const CustomTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: Array<{
payload: { name: string; count: number; value: number };
}>;
}) => {
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{data.name}</p>
<p className="text-sm">
{data.count} invoice{data.count !== 1 ? "s" : ""}
</p>
<p className="text-sm">{formatCurrency(data.value)}</p>
</div>
);
}
return null;
};
if (chartData.length === 0) { if (chartData.length === 0) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
@@ -127,11 +127,13 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
{chartData.map((entry, index) => ( {chartData.map((entry, index) => (
<Cell <Cell
key={`cell-${index}`} key={`cell-${index}`}
fill={COLORS[entry.status as keyof typeof COLORS]} fill={
STATUS_COLORS[entry.status as keyof typeof STATUS_COLORS]
}
/> />
))} ))}
</Pie> </Pie>
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<StatusTooltip />} />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -144,7 +146,8 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
<div <div
className="h-3 w-3 rounded-full" className="h-3 w-3 rounded-full"
style={{ style={{
backgroundColor: COLORS[item.status as keyof typeof COLORS], backgroundColor:
STATUS_COLORS[item.status as keyof typeof STATUS_COLORS],
}} }}
/> />
<span className="text-sm font-medium">{item.name}</span> <span className="text-sm font-medium">{item.name}</span>
@@ -152,7 +155,7 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
<div className="text-right"> <div className="text-right">
<p className="text-sm font-medium">{item.count}</p> <p className="text-sm font-medium">{item.count}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{formatCurrency(item.value)} {formatChartCurrency(item.value)}
</p> </p>
</div> </div>
</div> </div>
@@ -24,6 +24,43 @@ interface MonthlyMetricsChartProps {
invoices: Invoice[]; invoices: Invoice[];
} }
function MonthlyMetricsTooltip({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
payload: {
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
totalInvoices: number;
};
}>;
label?: string;
}) {
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p>
<div className="space-y-1 text-sm">
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
<p className="text-primary/80">Pending: {data.pendingInvoices}</p>
<p className="text-destructive">Overdue: {data.overdueInvoices}</p>
<p className="text-muted-foreground">Draft: {data.draftInvoices}</p>
<p className="text-foreground border-t pt-1 font-medium">
Total: {data.totalInvoices}
</p>
</div>
</div>
);
}
return null;
}
export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) { export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
// Process invoice data to create monthly metrics // Process invoice data to create monthly metrics
const monthlyData = invoices.reduce( const monthlyData = invoices.reduce(
@@ -95,49 +132,6 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
500 / (animationSpeedMultiplier || 1), 500 / (animationSpeedMultiplier || 1),
); );
const CustomTooltip = ({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
payload: {
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
totalInvoices: number;
};
}>;
label?: string;
}) => {
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p>
<div className="space-y-1 text-sm">
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
<p className="text-primary/80">
Pending: {data.pendingInvoices}
</p>
<p className="text-destructive">
Overdue: {data.overdueInvoices}
</p>
<p className="text-muted-foreground">
Draft: {data.draftInvoices}
</p>
<p className="text-foreground font-medium border-t pt-1">
Total: {data.totalInvoices}
</p>
</div>
</div>
);
}
return null;
};
if (chartData.length === 0) { if (chartData.length === 0) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
@@ -169,7 +163,7 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
tickLine={false} tickLine={false}
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }} tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
/> />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<MonthlyMetricsTooltip />} />
<Bar <Bar
dataKey="draftInvoices" dataKey="draftInvoices"
stackId="a" stackId="a"
@@ -235,9 +229,7 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
<span className="text-xs">Pending</span> <span className="text-xs">Pending</span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div className="bg-destructive h-3 w-3 rounded-full" />
className="h-3 w-3 rounded-full bg-destructive"
/>
<span className="text-xs">Overdue</span> <span className="text-xs">Overdue</span>
</div> </div>
</div> </div>
@@ -10,8 +10,6 @@ import {
} from "recharts"; } from "recharts";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface RevenueChartProps { interface RevenueChartProps {
data: { data: {
month: string; month: string;
@@ -91,7 +89,11 @@ export function RevenueChart({ data }: RevenueChartProps) {
<AreaChart data={chartData}> <AreaChart data={chartData}>
<defs> <defs>
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0.4} /> <stop
offset="5%"
stopColor="hsl(217, 91%, 60%)"
stopOpacity={0.4}
/>
<stop <stop
offset="95%" offset="95%"
stopColor="hsl(217, 91%, 60%)" stopColor="hsl(217, 91%, 60%)"
@@ -0,0 +1,101 @@
"use client";
import { Shield } from "lucide-react";
import { toast } from "sonner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { api } from "~/trpc/react";
export function AdministrationContent() {
const {
data: accounts = [],
refetch,
error,
} = api.settings.listAccounts.useQuery();
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({
onSuccess: () => {
toast.success("Account role updated");
void refetch();
},
onError: (mutationError: { message: string }) => {
toast.error(`Failed to update role: ${mutationError.message}`);
},
});
if (error) {
return (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
Administration
</CardTitle>
<CardDescription>
Administrative access is required for this page.
</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
Accounts
</CardTitle>
<CardDescription>
Manage account access and roles without opening customer data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{accounts.map((account) => (
<div
key={account.id}
className="border-border flex flex-col gap-3 border p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="text-sm font-medium">{account.name}</p>
<p className="text-muted-foreground truncate text-xs">
{account.email}
</p>
<p className="text-muted-foreground mt-1 text-xs">
Created {new Date(account.createdAt).toLocaleDateString()}
</p>
</div>
<Select
value={account.role}
onValueChange={(role) =>
updateAccountRoleMutation.mutate({
userId: account.id,
role: role as "user" | "admin",
})
}
>
<SelectTrigger className="w-full sm:w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
))}
</CardContent>
</Card>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { DataTableSkeleton } from "~/components/data/data-table";
import { PageHeader } from "~/components/layout/page-header";
import { HydrateClient } from "~/trpc/server";
import { AdministrationContent } from "./_components/administration-content";
export default async function AdministrationPage() {
return (
<div className="page-enter space-y-6">
<PageHeader
title="Administration"
description="Manage account access and platform administration"
variant="gradient"
/>
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
<AdministrationContent />
</Suspense>
</HydrateClient>
</div>
);
}
+245 -52
View File
@@ -68,20 +68,39 @@ export default function ExpensesPage() {
const { data: clients = [] } = api.clients.getAll.useQuery(); const { data: clients = [] } = api.clients.getAll.useQuery();
const create = api.expenses.create.useMutation({ const create = api.expenses.create.useMutation({
onSuccess: () => { toast.success("Expense added"); void utils.expenses.getAll.invalidate(); setOpen(false); setForm(defaultForm); }, onSuccess: () => {
toast.success("Expense added");
void utils.expenses.getAll.invalidate();
setOpen(false);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const update = api.expenses.update.useMutation({ const update = api.expenses.update.useMutation({
onSuccess: () => { toast.success("Expense updated"); void utils.expenses.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); }, onSuccess: () => {
toast.success("Expense updated");
void utils.expenses.getAll.invalidate();
setOpen(false);
setEditId(null);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const del = api.expenses.delete.useMutation({ const del = api.expenses.delete.useMutation({
onSuccess: () => { toast.success("Expense deleted"); void utils.expenses.getAll.invalidate(); setDeleteId(null); }, onSuccess: () => {
toast.success("Expense deleted");
void utils.expenses.getAll.invalidate();
setDeleteId(null);
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const handleOpen = () => { setEditId(null); setForm(defaultForm); setOpen(true); }; const handleOpen = () => {
const handleEdit = (expense: typeof expenses[0]) => { setEditId(null);
setForm(defaultForm);
setOpen(true);
};
const handleEdit = (expense: (typeof expenses)[0]) => {
setEditId(expense.id); setEditId(expense.id);
setForm({ setForm({
date: new Date(expense.date), date: new Date(expense.date),
@@ -98,21 +117,45 @@ export default function ExpensesPage() {
setOpen(true); setOpen(true);
}; };
const handleSubmit = () => { const handleSubmit = () => {
if (!form.description.trim()) { toast.error("Description is required"); return; } if (!form.description.trim()) {
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; } toast.error("Description is required");
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined, taxDeductible: form.taxDeductible }; return;
}
if (form.amount <= 0) {
toast.error("Amount must be greater than 0");
return;
}
const payload = {
...form,
clientId: form.clientId || undefined,
category: form.category || undefined,
notes: form.notes || undefined,
taxDeductible: form.taxDeductible,
};
if (editId) update.mutate({ id: editId, ...payload }); if (editId) update.mutate({ id: editId, ...payload });
else create.mutate(payload); else create.mutate(payload);
}; };
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0); const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0); const billableTotal = expenses
const deductibleTotal = expenses.filter((e) => e.taxDeductible).reduce((s, e) => s + e.amount, 0); .filter((e) => e.billable)
.reduce((s, e) => s + e.amount, 0);
const deductibleTotal = expenses
.filter((e) => e.taxDeductible)
.reduce((s, e) => s + e.amount, 0);
return ( return (
<div className="page-enter space-y-6 pb-6"> <div className="page-enter space-y-6 pb-6">
<PageHeader title="Expenses" description="Track billable and non-billable expenses" variant="gradient"> <PageHeader
<Button onClick={handleOpen} variant="default" className="hover-lift shadow-md"> title="Expenses"
description="Track billable and non-billable expenses"
variant="gradient"
>
<Button
onClick={handleOpen}
variant="default"
className="hover-lift shadow-md"
>
<Plus className="mr-2 h-5 w-5" /> Add Expense <Plus className="mr-2 h-5 w-5" /> Add Expense
</Button> </Button>
</PageHeader> </PageHeader>
@@ -121,25 +164,39 @@ export default function ExpensesPage() {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p> <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
<p className="mt-1 text-2xl font-bold">{formatCurrency(totalExpenses)}</p> Total
</p>
<p className="mt-1 text-2xl font-bold">
{formatCurrency(totalExpenses)}
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Billable</p> <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p> Billable
</p>
<p className="text-primary mt-1 text-2xl font-bold">
{formatCurrency(billableTotal)}
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Deductible</p> <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
<p className="mt-1 text-2xl font-bold text-green-600">{formatCurrency(deductibleTotal)}</p> Deductible
</p>
<p className="mt-1 text-2xl font-bold text-green-600">
{formatCurrency(deductibleTotal)}
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p> <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
Count
</p>
<p className="mt-1 text-2xl font-bold">{expenses.length}</p> <p className="mt-1 text-2xl font-bold">{expenses.length}</p>
</CardContent> </CardContent>
</Card> </Card>
@@ -154,34 +211,84 @@ export default function ExpensesPage() {
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{isLoading ? ( {isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">Loading</div> <div className="text-muted-foreground p-6 text-center text-sm">
Loading
</div>
) : expenses.length === 0 ? ( ) : expenses.length === 0 ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" /> <Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground text-sm">No expenses yet. Add your first expense.</p> <p className="text-muted-foreground text-sm">
No expenses yet. Add your first expense.
</p>
</div> </div>
) : ( ) : (
<div className="divide-y"> <div className="divide-y">
{expenses.map((expense) => ( {expenses.map((expense) => (
<div key={expense.id} className="flex items-start justify-between gap-3 p-4"> <div
key={expense.id}
className="flex items-start justify-between gap-3 p-4"
>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{expense.description}</p> <p className="font-medium">{expense.description}</p>
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>} {expense.billable && (
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>} <Badge variant="secondary" className="text-xs">
{expense.taxDeductible && <Badge variant="outline" className="text-xs text-green-600 border-green-300">Tax Deductible</Badge>} Billable
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>} </Badge>
)}
{expense.reimbursable && (
<Badge variant="outline" className="text-xs">
Reimbursable
</Badge>
)}
{expense.taxDeductible && (
<Badge
variant="outline"
className="border-green-300 text-xs text-green-600"
>
Tax Deductible
</Badge>
)}
{expense.category && (
<Badge variant="outline" className="text-xs">
{expense.category}
</Badge>
)}
</div> </div>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
{new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(expense.date))} {new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(expense.date))}
{expense.client ? ` · ${expense.client.name}` : ""} {expense.client ? ` · ${expense.client.name}` : ""}
</p> </p>
{expense.notes && <p className="text-muted-foreground mt-1 text-xs">{expense.notes}</p>} {expense.notes && (
<p className="text-muted-foreground mt-1 text-xs">
{expense.notes}
</p>
)}
</div> </div>
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 items-center gap-2">
<p className="font-semibold">{formatCurrency(expense.amount, expense.currency)}</p> <p className="font-semibold">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(expense)}><Pencil className="h-3.5 w-3.5" /></Button> {formatCurrency(expense.amount, expense.currency)}
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(expense.id)}><Trash2 className="h-3.5 w-3.5" /></Button> </p>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleEdit(expense)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive h-8 w-8 p-0"
onClick={() => setDeleteId(expense.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div> </div>
</div> </div>
))} ))}
@@ -199,70 +306,150 @@ export default function ExpensesPage() {
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>Description *</Label> <Label>Description *</Label>
<Input value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} placeholder="e.g. Laptop charger" /> <Input
value={form.description}
onChange={(e) =>
setForm((p) => ({ ...p, description: e.target.value }))
}
placeholder="e.g. Laptop charger"
/>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-2"> <div className="space-y-2">
<Label>Amount *</Label> <Label>Amount *</Label>
<NumberInput value={form.amount} onChange={(v) => setForm((p) => ({ ...p, amount: v }))} min={0} step={0.01} /> <NumberInput
value={form.amount}
onChange={(v) => setForm((p) => ({ ...p, amount: v }))}
min={0}
step={0.01}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Currency</Label> <Label>Currency</Label>
<Select value={form.currency} onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}> <Select
<SelectTrigger><SelectValue /></SelectTrigger> value={form.currency}
<SelectContent>{SUPPORTED_CURRENCIES.map((c) => <SelectItem key={c.code} value={c.code}>{c.code}</SelectItem>)}</SelectContent> onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_CURRENCIES.map((c) => (
<SelectItem key={c.code} value={c.code}>
{c.code}
</SelectItem>
))}
</SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-2"> <div className="space-y-2">
<Label>Date</Label> <Label>Date</Label>
<DatePicker date={form.date} onDateChange={(d) => setForm((p) => ({ ...p, date: d ?? new Date() }))} className="w-full" /> <DatePicker
date={form.date}
onDateChange={(d) =>
setForm((p) => ({ ...p, date: d ?? new Date() }))
}
className="w-full"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Category</Label> <Label>Category</Label>
<Select value={form.category || "none"} onValueChange={(v) => setForm((p) => ({ ...p, category: v === "none" ? "" : v }))}> <Select
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger> value={form.category || "none"}
onValueChange={(v) =>
setForm((p) => ({ ...p, category: v === "none" ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
{EXPENSE_CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)} {EXPENSE_CATEGORIES.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Client (optional)</Label> <Label>Client (optional)</Label>
<Select value={form.clientId || "none"} onValueChange={(v) => setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))}> <Select
<SelectTrigger><SelectValue placeholder="No client" /></SelectTrigger> value={form.clientId || "none"}
onValueChange={(v) =>
setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder="No client" />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">No client</SelectItem> <SelectItem value="none">No client</SelectItem>
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)} {clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-wrap gap-6"> <div className="flex flex-wrap gap-6">
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} /> <Checkbox
checked={form.billable}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, billable: !!v }))
}
/>
<span className="text-sm">Billable</span> <span className="text-sm">Billable</span>
</label> </label>
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} /> <Checkbox
checked={form.reimbursable}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, reimbursable: !!v }))
}
/>
<span className="text-sm">Reimbursable</span> <span className="text-sm">Reimbursable</span>
</label> </label>
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.taxDeductible} onCheckedChange={(v) => setForm((p) => ({ ...p, taxDeductible: !!v }))} /> <Checkbox
checked={form.taxDeductible}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, taxDeductible: !!v }))
}
/>
<span className="text-sm">Tax Deductible</span> <span className="text-sm">Tax Deductible</span>
</label> </label>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Notes (optional)</Label> <Label>Notes (optional)</Label>
<Input value={form.notes} onChange={(e) => setForm((p) => ({ ...p, notes: e.target.value }))} placeholder="Additional details…" /> <Input
value={form.notes}
onChange={(e) =>
setForm((p) => ({ ...p, notes: e.target.value }))
}
placeholder="Additional details…"
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="outline" onClick={() => setOpen(false)}>
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}> Cancel
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Add Expense"} </Button>
<Button
onClick={handleSubmit}
disabled={create.isPending || update.isPending}
>
{create.isPending || update.isPending
? "Saving…"
: editId
? "Update"
: "Add Expense"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -276,8 +463,14 @@ export default function ExpensesPage() {
<DialogDescription>This action cannot be undone.</DialogDescription> <DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button> <Button variant="outline" onClick={() => setDeleteId(null)}>
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}> Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && del.mutate({ id: deleteId })}
disabled={del.isPending}
>
{del.isPending ? "Deleting…" : "Delete"} {del.isPending ? "Deleting…" : "Delete"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -53,14 +53,13 @@ const columns: ColumnDef<InvoiceItem>[] = [
return ( return (
<> <>
{/* Desktop: plain description */} {/* Desktop: plain description */}
<div className="hidden font-medium sm:block"> <div className="hidden font-medium sm:block">{item.description}</div>
{item.description}
</div>
{/* Mobile: description + date + hours @ rate stacked */} {/* Mobile: description + date + hours @ rate stacked */}
<div className="sm:hidden"> <div className="sm:hidden">
<p className="font-medium">{item.description}</p> <p className="font-medium">{item.description}</p>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
{formatDate(item.date)} &middot; {item.hours}h @ {formatCurrency(item.rate)}/hr {formatDate(item.date)} &middot; {item.hours}h @{" "}
{formatCurrency(item.rate)}/hr
</p> </p>
</div> </div>
</> </>
@@ -1,127 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import { Send, Loader2 } from "lucide-react";
interface SendInvoiceButtonProps {
invoiceId: string;
variant?: "default" | "outline" | "ghost" | "icon";
className?: string;
showResend?: boolean;
}
export function SendInvoiceButton({
invoiceId,
variant = "outline",
className,
showResend = false,
}: SendInvoiceButtonProps) {
const [isSending, setIsSending] = useState(false);
// Get utils for cache invalidation
const utils = api.useUtils();
// Use the new email API mutation
const sendInvoiceMutation = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
// Show detailed success message with delivery info
toast.success(data.message, {
description: `Email ID: ${data.emailId}`,
duration: 5000,
});
// Refresh invoice data to show updated status
void utils.invoices.getById.invalidate({ id: invoiceId });
},
onError: (error) => {
// Enhanced error handling with specific error types
console.error("Email send error:", error);
let errorMessage = "Failed to send invoice email";
let errorDescription = "";
if (error.message.includes("Invalid recipient")) {
errorMessage = "Invalid Email Address";
errorDescription =
"Please check the client's email address and try again.";
} else if (error.message.includes("domain not verified")) {
errorMessage = "Email Configuration Issue";
errorDescription = "Please contact support to configure email sending.";
} else if (error.message.includes("rate limit")) {
errorMessage = "Too Many Emails";
errorDescription = "Please wait a moment before sending another email.";
} else if (error.message.includes("no email address")) {
errorMessage = "No Email Address";
errorDescription = "This client doesn't have an email address on file.";
} else {
errorDescription = error.message;
}
toast.error(errorMessage, {
description: errorDescription,
duration: 6000,
});
},
});
const handleSendInvoice = async () => {
if (isSending) return;
setIsSending(true);
try {
await sendInvoiceMutation.mutateAsync({
invoiceId,
});
} catch (error) {
// Error is already handled by the mutation's onError
console.error("Send invoice error:", error);
} finally {
setIsSending(false);
}
};
if (variant === "icon") {
return (
<Button
onClick={handleSendInvoice}
disabled={isSending}
variant="ghost"
size="sm"
className={className}
>
{isSending ? (
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
) : (
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
)}
</Button>
);
}
return (
<Button
onClick={handleSendInvoice}
disabled={isSending}
variant={variant}
size="default"
className={`w-full shadow-sm ${className}`}
data-testid="send-invoice-button"
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Sending Email...</span>
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
<span>{showResend ? "Resend Invoice" : "Send Invoice"}</span>
</>
)}
</Button>
);
}
@@ -1,26 +0,0 @@
"use client";
import { InvoiceView } from "~/components/data/invoice-view";
import InvoiceForm from "~/components/forms/invoice-form";
interface UnifiedInvoicePageProps {
invoiceId: string;
mode: string;
}
export function UnifiedInvoicePage({
invoiceId,
mode,
}: UnifiedInvoicePageProps) {
return (
<div>
{/* Always render InvoiceForm to preserve state, but hide when in view mode */}
<div className={mode === "edit" ? "block" : "hidden"}>
<InvoiceForm invoiceId={invoiceId} />
</div>
{/* Show InvoiceView only when in view mode */}
{mode === "view" && <InvoiceView invoiceId={invoiceId} />}
</div>
);
}
+7 -9
View File
@@ -75,7 +75,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const handleMarkAsPaid = () => { const handleMarkAsPaid = () => {
updateStatus.mutate({ updateStatus.mutate({
id: invoiceId, id: invoiceId,
status: "paid" as StoredInvoiceStatus, status: "paid",
}); });
}; };
@@ -99,27 +99,25 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
}).format(new Date(date)); }).format(new Date(date));
}; };
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number, currency = invoice.currency) => {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency,
}).format(amount); }).format(amount);
}; };
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 storedStatus = invoice.status as StoredInvoiceStatus;
const effectiveStatus = getEffectiveInvoiceStatus( const effectiveStatus = getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus, storedStatus,
invoice.dueDate,
);
const isOverdue = isInvoiceOverdue(
invoice.status as StoredInvoiceStatus,
invoice.dueDate, invoice.dueDate,
); );
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate);
const getStatusType = (): StatusType => { const getStatusType = (): StatusType => {
return effectiveStatus as StatusType; return effectiveStatus;
}; };
return ( return (
+67 -13
View File
@@ -54,6 +54,32 @@ function SendEmailPageSkeleton() {
); );
} }
function plainTextToHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\n/g, "<br>");
}
function normalizeEmailNoteHtml(value: string) {
const visibleText = value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;|\u00a0/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
return visibleText ? value.trim() : "";
}
export default function SendEmailPage() { export default function SendEmailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
@@ -155,7 +181,10 @@ export default function SendEmailPage() {
issueDate: invoiceData.issueDate, issueDate: invoiceData.issueDate,
dueDate: invoiceData.dueDate, dueDate: invoiceData.dueDate,
status: invoiceData.status, status: invoiceData.status,
totalAmount: invoiceData.totalAmount,
taxRate: invoiceData.taxRate, taxRate: invoiceData.taxRate,
currency: invoiceData.currency,
emailMessage: invoiceData.emailMessage,
client: invoiceData.client client: invoiceData.client
? { ? {
name: invoiceData.client.name, name: invoiceData.client.name,
@@ -171,13 +200,21 @@ export default function SendEmailPage() {
: undefined, : undefined,
items: invoiceData.items?.map((item) => ({ items: invoiceData.items?.map((item) => ({
id: item.id, id: item.id,
date: item.date,
description: item.description,
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.amount,
})), })),
} }
: undefined; : undefined;
}, [invoiceData]); }, [invoiceData]);
const normalizedCustomMessage = useMemo(
() => normalizeEmailNoteHtml(customMessage),
[customMessage],
);
// Initialize email content when invoice loads // Initialize email content when invoice loads
useEffect(() => { useEffect(() => {
if (!invoice || isInitialized) return; if (!invoice || isInitialized) return;
@@ -191,6 +228,9 @@ export default function SendEmailPage() {
const defaultContent = ``; const defaultContent = ``;
setEmailContent(defaultContent); setEmailContent(defaultContent);
setCustomMessage(
invoice.emailMessage ? plainTextToHtml(invoice.emailMessage) : "",
);
setIsInitialized(true); setIsInitialized(true);
}, [invoice, isInitialized]); }, [invoice, isInitialized]);
@@ -222,7 +262,7 @@ export default function SendEmailPage() {
invoiceId, invoiceId,
customSubject: subject, customSubject: subject,
customContent: emailContent, customContent: emailContent,
customMessage: customMessage?.trim() || undefined, customMessage: normalizedCustomMessage,
useHtml: true, useHtml: true,
ccEmails: ccEmail.trim() || undefined, ccEmails: ccEmail.trim() || undefined,
bccEmails: bccEmail.trim() || undefined, bccEmails: bccEmail.trim() || undefined,
@@ -252,7 +292,7 @@ export default function SendEmailPage() {
if (!invoice) { if (!invoice) {
return ( return (
<div className="container mx-auto max-w-4xl p-6"> <div className="page-enter space-y-6">
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertDescription>Invoice not found.</AlertDescription> <AlertDescription>Invoice not found.</AlertDescription>
@@ -262,7 +302,7 @@ export default function SendEmailPage() {
} }
return ( return (
<div className="container mx-auto max-w-6xl space-y-6 pb-32"> <div className="page-enter space-y-6 pb-32">
<PageHeader <PageHeader
title={`Send Invoice ${invoice.invoiceNumber}`} title={`Send Invoice ${invoice.invoiceNumber}`}
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat( description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat(
@@ -366,7 +406,7 @@ export default function SendEmailPage() {
ccEmail={ccEmail} ccEmail={ccEmail}
bccEmail={bccEmail} bccEmail={bccEmail}
content={emailContent} content={emailContent}
customMessage={customMessage} customMessage={normalizedCustomMessage}
invoice={invoice} invoice={invoice}
className="min-w-0 border-0" className="min-w-0 border-0"
/> />
@@ -552,10 +592,9 @@ export default function SendEmailPage() {
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> <Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Send Invoice Email?</DialogTitle> <DialogTitle>Confirm</DialogTitle>
<DialogDescription> <DialogDescription>
This will send invoice #{invoice.invoiceNumber} to{" "} Send this invoice email to <strong>{toEmail}</strong>
<strong>{invoice.client?.email}</strong>
{ccEmail && ( {ccEmail && (
<> <>
{" "} {" "}
@@ -568,14 +607,30 @@ export default function SendEmailPage() {
and BCC to <strong>{bccEmail}</strong> and BCC to <strong>{bccEmail}</strong>
</> </>
)} )}
. ?
</DialogDescription>
{retryCount > 0 && ( {retryCount > 0 && (
<div className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground text-sm">
Retry attempt {retryCount} of 2 Retry attempt {retryCount} of 2
</p>
)}
</DialogHeader>
<div className="bg-muted/30 space-y-2 border p-3 text-sm">
<div>
<span className="text-muted-foreground">Subject: </span>
<span className="font-medium">{subject}</span>
</div>
<div>
<span className="text-muted-foreground">Attachment: </span>
<span>invoice-{invoice.invoiceNumber}.pdf</span>
</div>
{normalizedCustomMessage && (
<div>
<span className="text-muted-foreground">Email note: </span>
<span>Included</span>
</div> </div>
)} )}
</DialogDescription> </div>
</DialogHeader>
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
@@ -584,8 +639,7 @@ export default function SendEmailPage() {
Cancel Cancel
</Button> </Button>
<Button onClick={confirmSendEmail} variant="default"> <Button onClick={confirmSendEmail} variant="default">
<Send className="mr-2 h-4 w-4" /> Confirm
Send Email
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -23,7 +23,15 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { Eye, Edit, Trash2, FileText, CheckCircle, Send, ChevronDown } from "lucide-react"; import {
Eye,
Edit,
Trash2,
FileText,
CheckCircle,
Send,
ChevronDown,
} from "lucide-react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
@@ -45,11 +53,28 @@ interface Invoice {
createdById: string; createdById: string;
createdAt: Date; createdAt: Date;
updatedAt: Date | null; updatedAt: Date | null;
client?: { id: string; name: string; email: string | null; phone: string | null } | null; client?: {
business?: { id: string; name: string; email: string | null; phone: string | null } | null; id: string;
name: string;
email: string | null;
phone: string | null;
} | null;
business?: {
id: string;
name: string;
email: string | null;
phone: string | null;
} | null;
items?: Array<{ items?: Array<{
id: string; invoiceId: string; date: Date; description: string; id: string;
hours: number; rate: number; amount: number; position: number; createdAt: Date; invoiceId: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
position: number;
createdAt: Date;
}> | null; }> | null;
} }
@@ -58,10 +83,17 @@ interface InvoicesDataTableProps {
} }
const getStatusType = (invoice: Invoice): StatusType => const getStatusType = (invoice: Invoice): StatusType =>
getEffectiveInvoiceStatus(invoice.status as StoredInvoiceStatus, invoice.dueDate) as StatusType; getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
const formatDate = (date: Date) => const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { month: "short", day: "2-digit", year: "numeric" }).format(new Date(date)); new Intl.DateTimeFormat("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
}).format(new Date(date));
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const router = useRouter(); const router = useRouter();
@@ -84,7 +116,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const bulkDelete = api.invoices.bulkDelete.useMutation({ const bulkDelete = api.invoices.bulkDelete.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
toast.success(`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`); toast.success(
`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`,
);
void utils.invoices.getAll.invalidate(); void utils.invoices.getAll.invalidate();
setBulkDeleteDialogOpen(false); setBulkDeleteDialogOpen(false);
setPendingBulkDelete([]); setPendingBulkDelete([]);
@@ -94,7 +128,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({ const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
toast.success(`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`); toast.success(
`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`,
);
void utils.invoices.getAll.invalidate(); void utils.invoices.getAll.invalidate();
}, },
onError: (e) => toast.error(e.message ?? "Failed to update invoices"), onError: (e) => toast.error(e.message ?? "Failed to update invoices"),
@@ -105,7 +141,10 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
id: "select", id: "select",
header: ({ table }) => ( header: ({ table }) => (
<Checkbox <Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="Select all" aria-label="Select all"
data-action-button="true" data-action-button="true"
@@ -124,7 +163,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
}, },
{ {
accessorKey: "client.name", accessorKey: "client.name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Client" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Client" />
),
cell: ({ row }) => { cell: ({ row }) => {
const invoice = row.original; const invoice = row.original;
return ( return (
@@ -133,10 +174,17 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<FileText className="text-primary h-4 w-4" /> <FileText className="text-primary h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-medium">{invoice.client?.name ?? "—"}</p> <p className="truncate font-medium">
<p className="text-muted-foreground truncate text-xs sm:text-sm">{invoice.invoiceNumber}</p> {invoice.client?.name ?? "—"}
</p>
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{invoice.invoiceNumber}
</p>
<div className="mt-1 flex items-center gap-2 sm:hidden"> <div className="mt-1 flex items-center gap-2 sm:hidden">
<StatusBadge status={getStatusType(invoice)} className="text-xs" /> <StatusBadge
status={getStatusType(invoice)}
className="text-xs"
/>
<span className="text-foreground text-xs font-semibold"> <span className="text-foreground text-xs font-semibold">
{formatCurrency(invoice.totalAmount, invoice.currency)} {formatCurrency(invoice.totalAmount, invoice.currency)}
</span> </span>
@@ -148,38 +196,59 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
}, },
{ {
accessorKey: "issueDate", accessorKey: "issueDate",
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Date" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm">{formatDate(row.getValue("issueDate") as Date)}</p> <p className="truncate text-sm">
<p className="text-muted-foreground truncate text-xs">Due {formatDate(new Date(row.original.dueDate))}</p> {formatDate(row.getValue("issueDate"))}
</p>
<p className="text-muted-foreground truncate text-xs">
Due {formatDate(new Date(row.original.dueDate))}
</p>
</div> </div>
), ),
}, },
{ {
accessorKey: "status", accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<StatusBadge <StatusBadge
status={getStatusType(row.original)} status={getStatusType(row.original)}
className={getStatusType(row.original) === "sent" ? "status-pending" : ""} className={
getStatusType(row.original) === "sent" ? "status-pending" : ""
}
/> />
), ),
filterFn: (row, _id, value: string[]) => value.includes(getStatusType(row.original)), filterFn: (row, _id, value: string[]) =>
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" }, value.includes(getStatusType(row.original)),
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
}, },
{ {
accessorKey: "totalAmount", accessorKey: "totalAmount",
header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Amount" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="text-right"> <div className="text-right">
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{formatCurrency(row.getValue("totalAmount") as number, row.original.currency)} {formatCurrency(row.getValue("totalAmount"), row.original.currency)}
</p>
<p className="text-muted-foreground text-xs">
{row.original.items?.length ?? 0} items
</p> </p>
<p className="text-muted-foreground text-xs">{row.original.items?.length ?? 0} items</p>
</div> </div>
), ),
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" }, meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
}, },
{ {
id: "actions", id: "actions",
@@ -188,19 +257,34 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
return ( return (
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Link href={`/dashboard/invoices/${invoice.id}`}> <Link href={`/dashboard/invoices/${invoice.id}`}>
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true"> <Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
>
<Eye className="h-3.5 w-3.5" /> <Eye className="h-3.5 w-3.5" />
</Button> </Button>
</Link> </Link>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}> <Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true"> <Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
>
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
</Button> </Button>
</Link> </Link>
<Button <Button
variant="ghost" size="sm" variant="ghost"
size="sm"
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0" className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
onClick={(e) => { e.stopPropagation(); setInvoiceToDelete(invoice); setDeleteDialogOpen(true); }} onClick={(e) => {
e.stopPropagation();
setInvoiceToDelete(invoice);
setDeleteDialogOpen(true);
}}
data-action-button="true" data-action-button="true"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -237,12 +321,18 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
searchKey="invoiceNumber" searchKey="invoiceNumber"
searchPlaceholder="Search invoices..." searchPlaceholder="Search invoices..."
filterableColumns={filterableColumns} filterableColumns={filterableColumns}
onRowClick={(invoice) => router.push(`/dashboard/invoices/${invoice.id}`)} onRowClick={(invoice) =>
router.push(`/dashboard/invoices/${invoice.id}`)
}
selectionActions={(selected, clear) => ( selectionActions={(selected, clear) => (
<> <>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={bulkUpdateStatus.isPending}> <Button
variant="outline"
size="sm"
disabled={bulkUpdateStatus.isPending}
>
<Send className="mr-1.5 h-3.5 w-3.5" /> <Send className="mr-1.5 h-3.5 w-3.5" />
Mark as Mark as
<ChevronDown className="ml-1.5 h-3.5 w-3.5" /> <ChevronDown className="ml-1.5 h-3.5 w-3.5" />
@@ -306,16 +396,24 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<DialogDescription> <DialogDescription>
Are you sure you want to delete invoice{" "} Are you sure you want to delete invoice{" "}
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "} <strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
<strong>{invoiceToDelete?.client?.name}</strong>? This action cannot be undone. <strong>{invoiceToDelete?.client?.name}</strong>? This action
cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleteInvoice.isPending}> <Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={deleteInvoice.isPending}
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => invoiceToDelete && deleteInvoice.mutate({ id: invoiceToDelete.id })} onClick={() =>
invoiceToDelete &&
deleteInvoice.mutate({ id: invoiceToDelete.id })
}
disabled={deleteInvoice.isPending} disabled={deleteInvoice.isPending}
> >
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"} {deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
@@ -325,25 +423,40 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
</Dialog> </Dialog>
{/* Bulk delete dialog */} {/* Bulk delete dialog */}
<Dialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}> <Dialog
open={bulkDeleteDialogOpen}
onOpenChange={setBulkDeleteDialogOpen}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete {pendingBulkDelete.length} Invoice{pendingBulkDelete.length !== 1 ? "s" : ""}</DialogTitle> <DialogTitle>
Delete {pendingBulkDelete.length} Invoice
{pendingBulkDelete.length !== 1 ? "s" : ""}
</DialogTitle>
<DialogDescription> <DialogDescription>
This will permanently delete {pendingBulkDelete.length} invoice{pendingBulkDelete.length !== 1 ? "s" : ""}. This will permanently delete {pendingBulkDelete.length} invoice
This action cannot be undone. {pendingBulkDelete.length !== 1 ? "s" : ""}. This action cannot be
undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setBulkDeleteDialogOpen(false)} disabled={bulkDelete.isPending}> <Button
variant="outline"
onClick={() => setBulkDeleteDialogOpen(false)}
disabled={bulkDelete.isPending}
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })} onClick={() =>
bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })
}
disabled={bulkDelete.isPending} disabled={bulkDelete.isPending}
> >
{bulkDelete.isPending ? "Deleting..." : `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`} {bulkDelete.isPending
? "Deleting..."
: `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+185 -74
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { api } from "~/trpc/react"; import { api, type RouterOutputs } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header"; import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
@@ -18,12 +18,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "~/components/ui/tabs";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react"; import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
@@ -34,87 +29,81 @@ interface TemplateForm {
isDefault: boolean; isDefault: boolean;
} }
const defaultForm: TemplateForm = { name: "", type: "notes", content: "", isDefault: false }; const defaultForm: TemplateForm = {
name: "",
export default function TemplatesPage() { type: "notes",
const [open, setOpen] = useState(false); content: "",
const [editId, setEditId] = useState<string | null>(null); isDefault: false,
const [form, setForm] = useState<TemplateForm>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [tab, setTab] = useState<"notes" | "terms">("notes");
const utils = api.useUtils();
const { data: templates = [], isLoading } = api.invoiceTemplates.getAll.useQuery();
const create = api.invoiceTemplates.create.useMutation({
onSuccess: () => { toast.success("Template created"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setForm(defaultForm); },
onError: (e) => toast.error(e.message),
});
const update = api.invoiceTemplates.update.useMutation({
onSuccess: () => { toast.success("Template updated"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); },
onError: (e) => toast.error(e.message),
});
const del = api.invoiceTemplates.delete.useMutation({
onSuccess: () => { toast.success("Template deleted"); void utils.invoiceTemplates.getAll.invalidate(); setDeleteId(null); },
onError: (e) => toast.error(e.message),
});
const handleOpen = (type: "notes" | "terms") => {
setEditId(null);
setForm({ ...defaultForm, type });
setOpen(true);
};
const handleEdit = (t: typeof templates[0]) => {
setEditId(t.id);
setForm({ name: t.name, type: t.type as "notes" | "terms", content: t.content, isDefault: t.isDefault });
setOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) { toast.error("Name is required"); return; }
if (!form.content.trim()) { toast.error("Content is required"); return; }
if (editId) update.mutate({ id: editId, ...form });
else create.mutate(form);
}; };
const notesTemplates = templates.filter((t) => t.type === "notes"); type InvoiceTemplate = RouterOutputs["invoiceTemplates"]["getAll"][number];
const termsTemplates = templates.filter((t) => t.type === "terms");
const TemplateList = ({ items, type }: { items: typeof templates; type: "notes" | "terms" }) => ( interface TemplateListProps {
items: InvoiceTemplate[];
type: "notes" | "terms";
isLoading: boolean;
onCreate: (type: "notes" | "terms") => void;
onEdit: (template: InvoiceTemplate) => void;
onDelete: (id: string) => void;
}
function TemplateList({
items,
type,
isLoading,
onCreate,
onEdit,
onDelete,
}: TemplateListProps) {
return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-end"> <div className="flex justify-end">
<Button size="sm" onClick={() => handleOpen(type)}> <Button size="sm" onClick={() => onCreate(type)}>
<Plus className="mr-1.5 h-3.5 w-3.5" /> New {type === "notes" ? "Notes" : "Terms"} Template <Plus className="mr-1.5 h-3.5 w-3.5" /> New{" "}
{type === "notes" ? "Notes" : "Terms"} Template
</Button> </Button>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm">Loading</div> <div className="text-muted-foreground py-8 text-center text-sm">
Loading...
</div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="text-muted-foreground py-8 text-center text-sm">
No {type} templates yet. No {type} templates yet.
</div> </div>
) : ( ) : (
items.map((t) => ( items.map((template) => (
<Card key={t.id}> <Card key={template.id}>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-medium">{t.name}</p> <p className="font-medium">{template.name}</p>
{t.isDefault && ( {template.isDefault && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
<Star className="mr-1 h-3 w-3" /> Default <Star className="mr-1 h-3 w-3" /> Default
</Badge> </Badge>
)} )}
</div> </div>
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap"> <p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap">
{t.content} {template.content}
</p> </p>
</div> </div>
<div className="flex flex-shrink-0 gap-1"> <div className="flex flex-shrink-0 gap-1">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(t)}> <Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onEdit(template)}
>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(t.id)}> <Button
variant="ghost"
size="sm"
className="text-destructive h-8 w-8 p-0"
onClick={() => onDelete(template.id)}
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -125,6 +114,77 @@ export default function TemplatesPage() {
)} )}
</div> </div>
); );
}
export default function TemplatesPage() {
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<TemplateForm>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [tab, setTab] = useState<"notes" | "terms">("notes");
const utils = api.useUtils();
const { data: templates = [], isLoading } =
api.invoiceTemplates.getAll.useQuery();
const create = api.invoiceTemplates.create.useMutation({
onSuccess: () => {
toast.success("Template created");
void utils.invoiceTemplates.getAll.invalidate();
setOpen(false);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message),
});
const update = api.invoiceTemplates.update.useMutation({
onSuccess: () => {
toast.success("Template updated");
void utils.invoiceTemplates.getAll.invalidate();
setOpen(false);
setEditId(null);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message),
});
const del = api.invoiceTemplates.delete.useMutation({
onSuccess: () => {
toast.success("Template deleted");
void utils.invoiceTemplates.getAll.invalidate();
setDeleteId(null);
},
onError: (e) => toast.error(e.message),
});
const handleOpen = (type: "notes" | "terms") => {
setEditId(null);
setForm({ ...defaultForm, type });
setOpen(true);
};
const handleEdit = (t: InvoiceTemplate) => {
setEditId(t.id);
setForm({
name: t.name,
type: t.type as "notes" | "terms",
content: t.content,
isDefault: t.isDefault,
});
setOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) {
toast.error("Name is required");
return;
}
if (!form.content.trim()) {
toast.error("Content is required");
return;
}
if (editId) update.mutate({ id: editId, ...form });
else create.mutate(form);
};
const notesTemplates = templates.filter((t) => t.type === "notes");
const termsTemplates = templates.filter((t) => t.type === "terms");
return ( return (
<div className="page-enter space-y-6 pb-6"> <div className="page-enter space-y-6 pb-6">
@@ -137,17 +197,33 @@ export default function TemplatesPage() {
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}> <Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes"> <TabsTrigger value="notes">
<FileText className="mr-1.5 h-4 w-4" /> Notes ({notesTemplates.length}) <FileText className="mr-1.5 h-4 w-4" /> Notes (
{notesTemplates.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="terms"> <TabsTrigger value="terms">
<FileText className="mr-1.5 h-4 w-4" /> Terms ({termsTemplates.length}) <FileText className="mr-1.5 h-4 w-4" /> Terms (
{termsTemplates.length})
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="notes" className="mt-4"> <TabsContent value="notes" className="mt-4">
<TemplateList items={notesTemplates} type="notes" /> <TemplateList
items={notesTemplates}
type="notes"
isLoading={isLoading}
onCreate={handleOpen}
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent> </TabsContent>
<TabsContent value="terms" className="mt-4"> <TabsContent value="terms" className="mt-4">
<TemplateList items={termsTemplates} type="terms" /> <TemplateList
items={termsTemplates}
type="terms"
isLoading={isLoading}
onCreate={handleOpen}
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -155,16 +231,29 @@ export default function TemplatesPage() {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{editId ? "Edit Template" : "New Template"}</DialogTitle> <DialogTitle>
{editId ? "Edit Template" : "New Template"}
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>Name *</Label> <Label>Name *</Label>
<Input value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Standard Payment Terms" /> <Input
value={form.name}
onChange={(e) =>
setForm((p) => ({ ...p, name: e.target.value }))
}
placeholder="e.g. Standard Payment Terms"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Type</Label> <Label>Type</Label>
<Tabs value={form.type} onValueChange={(v) => setForm((p) => ({ ...p, type: v as "notes" | "terms" }))}> <Tabs
value={form.type}
onValueChange={(v) =>
setForm((p) => ({ ...p, type: v as "notes" | "terms" }))
}
>
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes">Notes</TabsTrigger> <TabsTrigger value="notes">Notes</TabsTrigger>
<TabsTrigger value="terms">Terms</TabsTrigger> <TabsTrigger value="terms">Terms</TabsTrigger>
@@ -175,20 +264,36 @@ export default function TemplatesPage() {
<Label>Content *</Label> <Label>Content *</Label>
<Textarea <Textarea
value={form.content} value={form.content}
onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} onChange={(e) =>
setForm((p) => ({ ...p, content: e.target.value }))
}
placeholder="Template content…" placeholder="Template content…"
className="min-h-[120px]" className="min-h-[120px]"
/> />
</div> </div>
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.isDefault} onCheckedChange={(v) => setForm((p) => ({ ...p, isDefault: !!v }))} /> <Checkbox
checked={form.isDefault}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, isDefault: !!v }))
}
/>
<span className="text-sm">Set as default for {form.type}</span> <span className="text-sm">Set as default for {form.type}</span>
</label> </label>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="outline" onClick={() => setOpen(false)}>
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}> Cancel
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Create"} </Button>
<Button
onClick={handleSubmit}
disabled={create.isPending || update.isPending}
>
{create.isPending || update.isPending
? "Saving…"
: editId
? "Update"
: "Create"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -202,8 +307,14 @@ export default function TemplatesPage() {
<DialogDescription>This action cannot be undone.</DialogDescription> <DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button> <Button variant="outline" onClick={() => setDeleteId(null)}>
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}> Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && del.mutate({ id: deleteId })}
disabled={del.isPending}
>
{del.isPending ? "Deleting…" : "Delete"} {del.isPending ? "Deleting…" : "Delete"}
</Button> </Button>
</DialogFooter> </DialogFooter>
+9 -4
View File
@@ -28,9 +28,9 @@ import type { DashboardStats, RecentInvoice } from "./types";
// Hero section with clean mono design // Hero section with clean mono design
// Enhanced stats cards with better visuals // Enhanced stats cards with better visuals
function DashboardStats({ stats }: { stats: DashboardStats }) { // TODO: Import RouterOutput type function DashboardStats({ stats }: { stats: DashboardStats }) {
// TODO: Import RouterOutput type
const formatTrend = (value: number, isCount = false) => { const formatTrend = (value: number, isCount = false) => {
if (isCount) { if (isCount) {
return value > 0 ? `+${value}` : value.toString(); return value > 0 ? `+${value}` : value.toString();
@@ -193,7 +193,8 @@ function QuickActions() {
<Link <Link
key={action.title} key={action.title}
href={action.href} href={action.href}
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${action.featured className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${
action.featured
? "border-foreground/20 bg-muted/50 hover:bg-muted" ? "border-foreground/20 bg-muted/50 hover:bg-muted"
: "border-border bg-background hover:bg-muted/50" : "border-border bg-background hover:bg-muted/50"
}`} }`}
@@ -310,7 +311,11 @@ async function CurrentWork() {
} }
// Enhanced recent activity // Enhanced recent activity
async function RecentActivity({ recentInvoices }: { recentInvoices: RecentInvoice[] }) { async function RecentActivity({
recentInvoices,
}: {
recentInvoices: RecentInvoice[];
}) {
// Use passed recentInvoices instead of fetching all // Use passed recentInvoices instead of fetching all
const getStatusStyle = (status: string) => { const getStatusStyle = (status: string) => {
+501 -113
View File
@@ -6,7 +6,13 @@ import { PageHeader } from "~/components/layout/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { StatusBadge } from "~/components/data/status-badge"; import { StatusBadge } from "~/components/data/status-badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { formatCurrency } from "~/lib/currency"; import { formatCurrency } from "~/lib/currency";
@@ -23,7 +29,15 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
} from "recharts"; } from "recharts";
import { TrendingUp, DollarSign, Clock, Users, Download, Receipt, FileText } from "lucide-react"; import {
TrendingUp,
DollarSign,
Clock,
Users,
Download,
Receipt,
FileText,
} from "lucide-react";
function toNumericChartValue(value: unknown) { function toNumericChartValue(value: unknown) {
const numericValue = typeof value === "number" ? value : Number(value ?? 0); const numericValue = typeof value === "number" ? value : Number(value ?? 0);
@@ -31,20 +45,22 @@ function toNumericChartValue(value: unknown) {
} }
export default function ReportsPage() { export default function ReportsPage() {
const { data: invoices = [], isLoading: invoicesLoading } = api.invoices.getAll.useQuery(); const { data: invoices = [], isLoading: invoicesLoading } =
const { data: expenses = [], isLoading: expensesLoading } = api.expenses.getAll.useQuery(); api.invoices.getAll.useQuery();
const { data: expenses = [], isLoading: expensesLoading } =
api.expenses.getAll.useQuery();
const { data: stats } = api.dashboard.getStats.useQuery(); const { data: stats } = api.dashboard.getStats.useQuery();
const isLoading = invoicesLoading || expensesLoading; const isLoading = invoicesLoading || expensesLoading;
const now = new Date(); const currentYear = new Date().getFullYear();
const currentYear = now.getFullYear();
const [taxYear, setTaxYear] = useState(String(currentYear)); const [taxYear, setTaxYear] = useState(String(currentYear));
// Overview data (last 12 months) // Overview data (last 12 months)
const overviewData = useMemo(() => { const overviewData = useMemo(() => {
if (!invoices.length) return null; if (!invoices.length) return null;
const now = new Date();
const monthMap: Record<string, number> = {}; const monthMap: Record<string, number> = {};
for (let i = 11; i >= 0; i--) { for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
@@ -57,7 +73,10 @@ export default function ReportsPage() {
let totalHours = 0; let totalHours = 0;
for (const inv of invoices) { for (const inv of invoices) {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
);
if (status === "paid") { if (status === "paid") {
totalRevenue += inv.totalAmount; totalRevenue += inv.totalAmount;
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`; const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`;
@@ -69,28 +88,54 @@ export default function ReportsPage() {
} }
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({ const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
month: new Date(month + "-01").toLocaleDateString("en-US", { month: "short", year: "2-digit" }), month: new Date(month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
revenue, revenue,
})); }));
const clientMap: Record<string, { name: string; revenue: number }> = {}; const clientMap: Record<string, { name: string; revenue: number }> = {};
for (const inv of invoices) { for (const inv of invoices) {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
);
if (status === "paid" && inv.client) { if (status === "paid" && inv.client) {
const id = inv.client.id; const id = inv.client.id;
if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0 }; const entry = (clientMap[id] ??= {
clientMap[id]!.revenue += inv.totalAmount; name: inv.client.name,
revenue: 0,
});
entry.revenue += inv.totalAmount;
} }
} }
const topClients = Object.values(clientMap).sort((a, b) => b.revenue - a.revenue).slice(0, 6); const topClients = Object.values(clientMap)
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 6);
const statusCount: Record<string, number> = { draft: 0, sent: 0, paid: 0, overdue: 0 }; const statusCount: Record<string, number> = {
draft: 0,
sent: 0,
paid: 0,
overdue: 0,
};
for (const inv of invoices) { for (const inv of invoices) {
const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); const s = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
);
statusCount[s] = (statusCount[s] ?? 0) + 1; statusCount[s] = (statusCount[s] ?? 0) + 1;
} }
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, statusCount }; return {
revenueByMonth,
topClients,
totalRevenue,
totalPending,
totalHours,
statusCount,
};
}, [invoices]); }, [invoices]);
// Tax summary for selected year // Tax summary for selected year
@@ -98,16 +143,45 @@ export default function ReportsPage() {
const year = parseInt(taxYear); const year = parseInt(taxYear);
const yearInvoices = invoices.filter((inv) => { const yearInvoices = invoices.filter((inv) => {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); const status = getEffectiveInvoiceStatus(
return status === "paid" && new Date(inv.issueDate).getFullYear() === year; inv.status as StoredInvoiceStatus,
inv.dueDate,
);
return (
status === "paid" && new Date(inv.issueDate).getFullYear() === year
);
}); });
const yearExpenses = expenses.filter((exp) => new Date(exp.date).getFullYear() === year); const yearExpenses = expenses.filter(
(exp) => new Date(exp.date).getFullYear() === year,
);
const grossIncome = yearInvoices.reduce((s, inv) => s + inv.totalAmount, 0); const getSubtotal = (inv: (typeof yearInvoices)[number]) => {
const taxCollected = yearInvoices.reduce((s, inv) => s + inv.totalAmount * (inv.taxRate ?? 0), 0); const itemSubtotal = (inv.items ?? []).reduce(
(s, item) => s + item.amount,
0,
);
if (itemSubtotal > 0) return itemSubtotal;
const taxMultiplier = 1 + (inv.taxRate ?? 0) / 100;
return taxMultiplier > 0
? inv.totalAmount / taxMultiplier
: inv.totalAmount;
};
const grossIncome = yearInvoices.reduce(
(s, inv) => s + getSubtotal(inv),
0,
);
const taxCollected = yearInvoices.reduce(
(s, inv) => s + (inv.totalAmount - getSubtotal(inv)),
0,
);
const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0); const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0);
const deductibleExpenses = yearExpenses const deductibleExpenses = yearExpenses
.filter((exp) => (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible) .filter(
(exp) =>
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible,
)
.reduce((s, exp) => s + exp.amount, 0); .reduce((s, exp) => s + exp.amount, 0);
const netProfit = grossIncome - deductibleExpenses; const netProfit = grossIncome - deductibleExpenses;
@@ -121,23 +195,49 @@ export default function ReportsPage() {
const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2]; const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2];
return { return {
label: `Q${q}`, label: `Q${q}`,
income: yearInvoices.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth())).reduce((s, inv) => s + inv.totalAmount, 0), income: yearInvoices
expenses: yearExpenses.filter((exp) => qMonths.includes(new Date(exp.date).getMonth())).reduce((s, exp) => s + exp.amount, 0), .filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth()))
.reduce((s, inv) => s + getSubtotal(inv), 0),
expenses: yearExpenses
.filter((exp) => qMonths.includes(new Date(exp.date).getMonth()))
.reduce((s, exp) => s + exp.amount, 0),
}; };
}); });
return { grossIncome, taxCollected, totalInvoiced: grossIncome + taxCollected, totalExpenses, deductibleExpenses, netProfit, selfEmploymentTax, federalEstimate, totalEstimated, quarters, yearInvoices, yearExpenses }; return {
grossIncome,
taxCollected,
totalInvoiced: grossIncome + taxCollected,
totalExpenses,
deductibleExpenses,
netProfit,
selfEmploymentTax,
federalEstimate,
totalEstimated,
quarters,
yearInvoices,
yearExpenses,
};
}, [invoices, expenses, taxYear]); }, [invoices, expenses, taxYear]);
const availableYears = useMemo(() => { const availableYears = useMemo(() => {
const years = new Set<number>([currentYear, currentYear - 1]); const years = new Set<number>([currentYear, currentYear - 1]);
for (const inv of invoices) years.add(new Date(inv.issueDate).getFullYear()); for (const inv of invoices)
years.add(new Date(inv.issueDate).getFullYear());
for (const exp of expenses) years.add(new Date(exp.date).getFullYear()); for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
return Array.from(years).sort((a, b) => b - a); return Array.from(years).sort((a, b) => b - a);
}, [invoices, expenses, currentYear]); }, [invoices, expenses, currentYear]);
const avgInvoice = invoices.length > 0 const avgInvoice =
? (overviewData?.totalRevenue ?? 0) / (invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 1) invoices.length > 0
? (overviewData?.totalRevenue ?? 0) /
(invoices.filter(
(i) =>
getEffectiveInvoiceStatus(
i.status as StoredInvoiceStatus,
i.dueDate,
) === "paid",
).length || 1)
: 0; : 0;
function exportCSV() { function exportCSV() {
@@ -148,14 +248,30 @@ export default function ReportsPage() {
"INCOME (Paid Invoices)", "INCOME (Paid Invoices)",
"Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total", "Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total",
...taxData.yearInvoices.map((inv) => { ...taxData.yearInvoices.map((inv) => {
const taxAmt = inv.totalAmount * (inv.taxRate ?? 0); const subtotal = (inv.items ?? []).reduce(
return [new Date(inv.issueDate).toLocaleDateString("en-US"), inv.invoiceNumber, `"${inv.client?.name ?? ""}"`, inv.totalAmount.toFixed(2), `${((inv.taxRate ?? 0) * 100).toFixed(1)}%`, taxAmt.toFixed(2), (inv.totalAmount + taxAmt).toFixed(2)].join(","); (s, item) => s + item.amount,
0,
);
const fallbackSubtotal =
inv.totalAmount / (1 + (inv.taxRate ?? 0) / 100);
const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal;
const taxAmt = inv.totalAmount - invoiceSubtotal;
return [
new Date(inv.issueDate).toLocaleDateString("en-US"),
inv.invoiceNumber,
`"${inv.client?.name ?? ""}"`,
invoiceSubtotal.toFixed(2),
`${(inv.taxRate ?? 0).toFixed(1)}%`,
taxAmt.toFixed(2),
inv.totalAmount.toFixed(2),
].join(",");
}), }),
`,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`, `,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`,
"", "",
"EXPENSES", "EXPENSES",
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible", "Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
...taxData.yearExpenses.map((exp) => [ ...taxData.yearExpenses.map((exp) =>
[
new Date(exp.date).toLocaleDateString("en-US"), new Date(exp.date).toLocaleDateString("en-US"),
`"${exp.description}"`, `"${exp.description}"`,
`"${exp.category ?? ""}"`, `"${exp.category ?? ""}"`,
@@ -163,8 +279,11 @@ export default function ReportsPage() {
exp.currency, exp.currency,
exp.billable ? "Yes" : "No", exp.billable ? "Yes" : "No",
exp.reimbursable ? "Yes" : "No", exp.reimbursable ? "Yes" : "No",
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible ? "Yes" : "No", (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible
].join(",")), ? "Yes"
: "No",
].join(","),
),
`,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`, `,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`,
"", "",
"TAX SUMMARY", "TAX SUMMARY",
@@ -176,7 +295,9 @@ export default function ReportsPage() {
`Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`, `Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`,
`Total Estimated Tax,${taxData.totalEstimated.toFixed(2)}`, `Total Estimated Tax,${taxData.totalEstimated.toFixed(2)}`,
]; ];
const blob = new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8;" }); const blob = new Blob([rows.join("\n")], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
@@ -188,9 +309,15 @@ export default function ReportsPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="page-enter space-y-6"> <div className="page-enter space-y-6">
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" /> <PageHeader
title="Reports"
description="Revenue and tax analytics"
variant="gradient"
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)} {Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
))}
</div> </div>
</div> </div>
); );
@@ -198,12 +325,20 @@ export default function ReportsPage() {
return ( return (
<div className="page-enter space-y-6 pb-6"> <div className="page-enter space-y-6 pb-6">
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" /> <PageHeader
title="Reports"
description="Revenue and tax analytics"
variant="gradient"
/>
<Tabs defaultValue="overview"> <Tabs defaultValue="overview">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="overview"><TrendingUp className="mr-1.5 h-4 w-4" /> Overview</TabsTrigger> <TabsTrigger value="overview">
<TabsTrigger value="tax"><FileText className="mr-1.5 h-4 w-4" /> Tax Summary</TabsTrigger> <TrendingUp className="mr-1.5 h-4 w-4" /> Overview
</TabsTrigger>
<TabsTrigger value="tax">
<FileText className="mr-1.5 h-4 w-4" /> Tax Summary
</TabsTrigger>
</TabsList> </TabsList>
{/* ── OVERVIEW TAB ── */} {/* ── OVERVIEW TAB ── */}
@@ -212,60 +347,139 @@ export default function ReportsPage() {
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-primary/10 rounded p-1.5"><DollarSign className="text-primary h-4 w-4" /></div> <div className="bg-primary/10 rounded p-1.5">
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p> <DollarSign className="text-primary h-4 w-4" />
</div> </div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalRevenue ?? 0)}</p> <p className="text-muted-foreground text-xs font-medium">
Total Revenue
</p>
</div>
<p className="mt-2 text-2xl font-bold">
{formatCurrency(overviewData?.totalRevenue ?? 0)}
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-yellow-500/10 rounded p-1.5"><Clock className="h-4 w-4 text-yellow-500" /></div> <div className="rounded bg-yellow-500/10 p-1.5">
<p className="text-muted-foreground text-xs font-medium">Pending</p> <Clock className="h-4 w-4 text-yellow-500" />
</div> </div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalPending ?? 0)}</p> <p className="text-muted-foreground text-xs font-medium">
Pending
</p>
</div>
<p className="mt-2 text-2xl font-bold">
{formatCurrency(overviewData?.totalPending ?? 0)}
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-blue-500/10 rounded p-1.5"><TrendingUp className="h-4 w-4 text-blue-500" /></div> <div className="rounded bg-blue-500/10 p-1.5">
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p> <TrendingUp className="h-4 w-4 text-blue-500" />
</div> </div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p> <p className="text-muted-foreground text-xs font-medium">
Avg Invoice
</p>
</div>
<p className="mt-2 text-2xl font-bold">
{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-green-500/10 rounded p-1.5"><Users className="h-4 w-4 text-green-500" /></div> <div className="rounded bg-green-500/10 p-1.5">
<p className="text-muted-foreground text-xs font-medium">Total Hours</p> <Users className="h-4 w-4 text-green-500" />
</div> </div>
<p className="mt-2 text-2xl font-bold">{(overviewData?.totalHours ?? 0).toFixed(1)}h</p> <p className="text-muted-foreground text-xs font-medium">
Total Hours
</p>
</div>
<p className="mt-2 text-2xl font-bold">
{(overviewData?.totalHours ?? 0).toFixed(1)}h
</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"><TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)</CardTitle> <CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-48 w-full md:h-64"> <div className="h-48 w-full md:h-64">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={overviewData?.revenueByMonth ?? []}> <AreaChart data={overviewData?.revenueByMonth ?? []}>
<defs> <defs>
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} /> id="revenueGrad"
<stop offset="95%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.02} /> x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="hsl(142, 76%, 36%)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="hsl(142, 76%, 36%)"
stopOpacity={0.02}
/>
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" /> <CartesianGrid
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} /> strokeDasharray="3 3"
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> className="stroke-border"
<Tooltip formatter={(value) => [formatCurrency(toNumericChartValue(value)), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} /> />
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} /> <XAxis
dataKey="month"
tick={{
fontSize: 11,
fill: "hsl(var(--muted-foreground))",
}}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{
fontSize: 11,
fill: "hsl(var(--muted-foreground))",
}}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) =>
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
}
/>
<Tooltip
formatter={(value) => [
formatCurrency(toNumericChartValue(value)),
"Revenue",
]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: 12,
}}
/>
<Area
type="monotone"
dataKey="revenue"
stroke="hsl(142, 76%, 36%)"
fill="url(#revenueGrad)"
strokeWidth={2}
dot={false}
/>
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -275,19 +489,62 @@ export default function ReportsPage() {
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"><Users className="h-5 w-5" /> Top Clients by Revenue</CardTitle> <CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" /> Top Clients by Revenue
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!overviewData?.topClients.length ? ( {!overviewData?.topClients.length ? (
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p> <p className="text-muted-foreground py-6 text-center text-sm">
No paid invoices yet.
</p>
) : ( ) : (
<div className="h-48 md:h-56"> <div className="h-48 md:h-56">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={overviewData.topClients} layout="vertical"> <BarChart
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> data={overviewData.topClients}
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} /> layout="vertical"
<Tooltip formatter={(value) => [formatCurrency(toNumericChartValue(value)), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} /> >
<Bar dataKey="revenue" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} /> <XAxis
type="number"
tick={{
fontSize: 11,
fill: "hsl(var(--muted-foreground))",
}}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) =>
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
}
/>
<YAxis
type="category"
dataKey="name"
tick={{
fontSize: 11,
fill: "hsl(var(--muted-foreground))",
}}
axisLine={false}
tickLine={false}
width={80}
/>
<Tooltip
formatter={(value) => [
formatCurrency(toNumericChartValue(value)),
"Revenue",
]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: 12,
}}
/>
<Bar
dataKey="revenue"
fill="hsl(142, 76%, 36%)"
radius={[0, 4, 4, 0]}
/>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -296,38 +553,76 @@ export default function ReportsPage() {
</Card> </Card>
<Card> <Card>
<CardHeader><CardTitle>Invoice Status Breakdown</CardTitle></CardHeader> <CardHeader>
<CardTitle>Invoice Status Breakdown</CardTitle>
</CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{Object.entries(overviewData?.statusCount ?? {}).map(([status, count]) => ( {Object.entries(overviewData?.statusCount ?? {}).map(
<div key={status} className="flex items-center justify-between"> ([status, count]) => (
<div
key={status}
className="flex items-center justify-between"
>
<StatusBadge status={status as never} /> <StatusBadge status={status as never} />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32"> <div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
<div className="bg-primary h-full rounded-full" style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }} /> <div
className="bg-primary h-full rounded-full"
style={{
width: `${invoices.length ? (count / invoices.length) * 100 : 0}%`,
}}
/>
</div> </div>
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span> <span className="text-muted-foreground w-8 text-right text-sm">
{count}
</span>
</div> </div>
</div> </div>
))} ),
{invoices.length === 0 && <p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>} )}
{invoices.length === 0 && (
<p className="text-muted-foreground py-6 text-center text-sm">
No invoices yet.
</p>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{stats && ( {stats && (
<Card> <Card>
<CardHeader><CardTitle>Recent Activity</CardTitle></CardHeader> <CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<div className="divide-y"> <div className="divide-y">
{stats.recentInvoices.map((inv) => ( {stats.recentInvoices.map((inv) => (
<div key={inv.id} className="flex items-center justify-between py-3"> <div
key={inv.id}
className="flex items-center justify-between py-3"
>
<div> <div>
<p className="font-medium">{inv.client?.name ?? "—"}</p> <p className="font-medium">{inv.client?.name ?? "—"}</p>
<p className="text-muted-foreground text-xs">{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</p> <p className="text-muted-foreground text-xs">
{new Date(inv.issueDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<StatusBadge status={getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate) as never} /> <StatusBadge
<p className="font-semibold">{formatCurrency(inv.totalAmount)}</p> status={
getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
) as never
}
/>
<p className="font-semibold">
{formatCurrency(inv.totalAmount)}
</p>
</div> </div>
</div> </div>
))} ))}
@@ -343,9 +638,15 @@ export default function ReportsPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm font-medium">Tax Year</span> <span className="text-sm font-medium">Tax Year</span>
<Select value={taxYear} onValueChange={setTaxYear}> <Select value={taxYear} onValueChange={setTaxYear}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger> <SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{availableYears.map((y) => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)} {availableYears.map((y) => (
<SelectItem key={y} value={String(y)}>
{y}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -357,17 +658,27 @@ export default function ReportsPage() {
{/* Income */} {/* Income */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"><DollarSign className="h-5 w-5" /> Income</CardTitle> <CardTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5" /> Income
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Gross Income (paid invoices)</span> <span className="text-muted-foreground">
<span className="font-medium">{formatCurrency(taxData.grossIncome)}</span> Gross Income (paid invoices)
</span>
<span className="font-medium">
{formatCurrency(taxData.grossIncome)}
</span>
</div> </div>
{taxData.taxCollected > 0 && ( {taxData.taxCollected > 0 && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax Collected from Clients</span> <span className="text-muted-foreground">
<span className="font-medium">{formatCurrency(taxData.taxCollected)}</span> Tax Collected from Clients
</span>
<span className="font-medium">
{formatCurrency(taxData.taxCollected)}
</span>
</div> </div>
)} )}
<Separator /> <Separator />
@@ -381,19 +692,31 @@ export default function ReportsPage() {
{/* Expenses */} {/* Expenses */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"><Receipt className="h-5 w-5" /> Expenses & Deductions</CardTitle> <CardTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" /> Expenses & Deductions
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Expenses</span> <span className="text-muted-foreground">Total Expenses</span>
<span className="font-medium">{formatCurrency(taxData.totalExpenses)}</span> <span className="font-medium">
{formatCurrency(taxData.totalExpenses)}
</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax-Deductible Expenses</span> <span className="text-muted-foreground">
<span className="font-medium text-green-600">{formatCurrency(taxData.deductibleExpenses)}</span> Tax-Deductible Expenses
</span>
<span className="font-medium text-green-600">
{formatCurrency(taxData.deductibleExpenses)}
</span>
</div> </div>
{taxData.totalExpenses > 0 && taxData.deductibleExpenses === 0 && ( {taxData.totalExpenses > 0 &&
<p className="text-muted-foreground text-xs">Mark expenses as "Tax Deductible" in the Expenses page to include them here.</p> taxData.deductibleExpenses === 0 && (
<p className="text-muted-foreground text-xs">
Mark expenses as &quot;Tax Deductible&quot; in the Expenses
page to include them here.
</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -401,54 +724,119 @@ export default function ReportsPage() {
{/* Estimated tax */} {/* Estimated tax */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"><FileText className="h-5 w-5" /> Estimated Tax Liability</CardTitle> <CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" /> Estimated Tax Liability
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Net Profit (income deductible expenses)</span> <span className="text-muted-foreground">
<span className="font-medium">{formatCurrency(taxData.netProfit)}</span> Net Profit (income deductible expenses)
</span>
<span className="font-medium">
{formatCurrency(taxData.netProfit)}
</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Self-Employment Tax (15.3% on 92.35% of net)</span> <span className="text-muted-foreground">
<span className="font-medium">{formatCurrency(taxData.selfEmploymentTax)}</span> Self-Employment Tax (15.3% on 92.35% of net)
</span>
<span className="font-medium">
{formatCurrency(taxData.selfEmploymentTax)}
</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Federal Income Tax (est. 22% bracket)</span> <span className="text-muted-foreground">
<span className="font-medium">{formatCurrency(taxData.federalEstimate)}</span> Federal Income Tax (est. 22% bracket)
</span>
<span className="font-medium">
{formatCurrency(taxData.federalEstimate)}
</span>
</div> </div>
<Separator /> <Separator />
<div className="flex justify-between text-lg font-bold"> <div className="flex justify-between text-lg font-bold">
<span>Total Estimated Tax</span> <span>Total Estimated Tax</span>
<span className="text-destructive">{formatCurrency(taxData.totalEstimated)}</span> <span className="text-destructive">
{formatCurrency(taxData.totalEstimated)}
</span>
</div> </div>
<p className="text-muted-foreground text-xs pt-1"> <p className="text-muted-foreground pt-1 text-xs">
Assumes US self-employment tax rules and the 22% federal bracket. Consult a tax professional for accurate filing. Assumes US self-employment tax rules and the 22% federal
bracket. Consult a tax professional for accurate filing.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
{/* Quarterly chart */} {/* Quarterly chart */}
<Card> <Card>
<CardHeader><CardTitle>Quarterly Breakdown</CardTitle></CardHeader> <CardHeader>
<CardTitle>Quarterly Breakdown</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<div className="h-48 md:h-64"> <div className="h-48 md:h-64">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={taxData.quarters}> <BarChart data={taxData.quarters}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" /> <CartesianGrid
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} /> strokeDasharray="3 3"
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> className="stroke-border"
<Tooltip />
formatter={(value, name) => [formatCurrency(toNumericChartValue(value)), name === "income" ? "Income" : "Expenses"]} <XAxis
contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} dataKey="label"
tick={{
fontSize: 11,
fill: "hsl(var(--muted-foreground))",
}}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{
fontSize: 11,
fill: "hsl(var(--muted-foreground))",
}}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) =>
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
}
/>
<Tooltip
formatter={(value, name) => [
formatCurrency(toNumericChartValue(value)),
name === "income" ? "Income" : "Expenses",
]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: 12,
}}
/>
<Bar
dataKey="income"
name="income"
fill="hsl(142, 76%, 36%)"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="expenses"
name="expenses"
fill="hsl(0, 84%, 60%)"
radius={[4, 4, 0, 0]}
opacity={0.75}
/> />
<Bar dataKey="income" name="income" fill="hsl(142, 76%, 36%)" radius={[4, 4, 0, 0]} />
<Bar dataKey="expenses" name="expenses" fill="hsl(0, 84%, 60%)" radius={[4, 4, 0, 0]} opacity={0.75} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="mt-2 flex justify-center gap-6 text-xs text-muted-foreground"> <div className="text-muted-foreground mt-2 flex justify-center gap-6 text-xs">
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" /> Income</span> <span className="flex items-center gap-1.5">
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" /> Expenses</span> <span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" />{" "}
Income
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" />{" "}
Expenses
</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -0,0 +1,124 @@
"use client";
import { BlobProvider } from "@react-pdf/renderer";
import {
InvoicePDF,
type InvoiceData,
type PDFGenerationSettings,
} from "~/lib/pdf-export";
const previewInvoice: InvoiceData = {
invoiceNumber: "BV-2026-001",
issueDate: new Date("2026-04-30T12:00:00.000Z"),
dueDate: new Date("2026-05-30T12:00:00.000Z"),
status: "sent",
totalAmount: 3150,
taxRate: 0,
currency: "USD",
notes: "Thank you for the work. Payment is due within 30 days.",
business: {
name: "Sample Studio",
email: "hello@beenvoice.test",
phone: "(555) 014-1024",
addressLine1: "100 Terminal Way",
city: "New York",
state: "NY",
postalCode: "10001",
country: "USA",
website: "beenvoice.test",
},
client: {
name: "Client Studio",
email: "ap@clientstudio.test",
addressLine1: "42 Market Street",
city: "Brooklyn",
state: "NY",
postalCode: "11201",
country: "USA",
},
items: [
{
date: new Date("2026-04-08T12:00:00.000Z"),
description: "Invoice workflow design and implementation",
hours: 12,
rate: 150,
amount: 1800,
},
{
date: new Date("2026-04-16T12:00:00.000Z"),
description: "Client import cleanup",
hours: 5,
rate: 150,
amount: 750,
},
{
date: new Date("2026-04-24T12:00:00.000Z"),
description: "Reporting polish",
hours: 4,
rate: 150,
amount: 600,
},
],
};
export function PdfPreviewFrame({
settings,
businessName,
}: {
settings: Required<PDFGenerationSettings>;
businessName: string;
}) {
const previewBusinessName =
businessName.trim() !== ""
? businessName
: (previewInvoice.business?.name ?? "Sample Studio");
const invoice = {
...previewInvoice,
business: {
...previewInvoice.business,
name: previewBusinessName,
},
};
return (
<div className="bg-muted/30 overflow-hidden border">
<div className="bg-background flex h-10 items-center justify-between border-b px-3">
<span className="text-muted-foreground text-xs font-medium">
PDF preview
</span>
<span className="text-muted-foreground text-xs">
Generated from sample invoice data
</span>
</div>
<BlobProvider
document={<InvoicePDF invoice={invoice} settings={settings} />}
>
{({ url, loading, error }) => {
if (loading) {
return (
<div className="text-muted-foreground flex aspect-[8.5/11] items-center justify-center p-6 text-sm">
Rendering PDF preview...
</div>
);
}
if (error || !url) {
return (
<div className="text-destructive flex aspect-[8.5/11] items-center justify-center p-6 text-sm">
PDF preview could not be rendered.
</div>
);
}
return (
<iframe
src={url}
title="Invoice PDF preview"
className="h-[640px] w-full bg-white"
/>
);
}}
</BlobProvider>
</div>
);
}
@@ -23,6 +23,7 @@ import {
Paintbrush, Paintbrush,
Type, Type,
} from "lucide-react"; } from "lucide-react";
import dynamic from "next/dynamic";
import { authClient } from "~/lib/auth-client"; import { authClient } from "~/lib/auth-client";
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useState } from "react";
@@ -62,6 +63,7 @@ import {
DialogTrigger, DialogTrigger,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { InputColor } from "~/components/ui/input-color";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
@@ -92,6 +94,18 @@ import {
type InterfaceTheme, type InterfaceTheme,
} from "~/lib/branding"; } from "~/lib/branding";
const PdfPreviewFrame = dynamic(
() => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame),
{
ssr: false,
loading: () => (
<div className="bg-muted/30 text-muted-foreground flex h-[680px] items-center justify-center border text-sm">
Loading PDF preview...
</div>
),
},
);
function hslChannelsToHex(channels?: string) { function hslChannelsToHex(channels?: string) {
const [hue, saturation, lightness] = const [hue, saturation, lightness] =
channels?.match(/[\d.]+/g)?.map(Number) ?? []; channels?.match(/[\d.]+/g)?.map(Number) ?? [];
@@ -158,6 +172,10 @@ function hexToHslChannels(hex: string) {
)}% ${Number((lightness * 100).toFixed(1))}%`; )}% ${Number((lightness * 100).toFixed(1))}%`;
} }
function isFullHexColor(value: string) {
return /^#[0-9A-Fa-f]{6}$/.test(value);
}
export function SettingsContent() { export function SettingsContent() {
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
// const session = { user: null } as any; // const session = { user: null } as any;
@@ -195,6 +213,7 @@ export function SettingsContent() {
pdfShowLogo, pdfShowLogo,
pdfShowPageNumbers, pdfShowPageNumbers,
updateAppearance, updateAppearance,
updateAppearanceDebounced,
isUpdating: appearanceUpdating, isUpdating: appearanceUpdating,
} = useAppearance(); } = useAppearance();
const activePreset = themePresets[interfaceTheme]; const activePreset = themePresets[interfaceTheme];
@@ -203,7 +222,9 @@ export function SettingsContent() {
activePreset.headingFontPreference !== headingFontPreference || activePreset.headingFontPreference !== headingFontPreference ||
activePreset.colorTheme !== colorTheme || activePreset.colorTheme !== colorTheme ||
activePreset.radiusPreference !== radiusPreference || activePreset.radiusPreference !== radiusPreference ||
activePreset.sidebarStyle !== sidebarStyle; activePreset.sidebarStyle !== sidebarStyle ||
activePreset.pdfTemplate !== pdfTemplate ||
activePreset.pdfAccentColor !== pdfAccentColor;
const customColorValue = customColor ?? "142.1 76.2% 36.3%"; const customColorValue = customColor ?? "142.1 76.2% 36.3%";
const selectAccent = (nextColorTheme: ColorTheme) => { const selectAccent = (nextColorTheme: ColorTheme) => {
updateAppearance({ updateAppearance({
@@ -249,10 +270,6 @@ export function SettingsContent() {
api.settings.getProfile.useQuery(); api.settings.getProfile.useQuery();
const isAdmin = profile?.role === "admin"; const isAdmin = profile?.role === "admin";
const { data: dataStats } = api.settings.getDataStats.useQuery(); const { data: dataStats } = api.settings.getDataStats.useQuery();
const { data: accounts = [], refetch: refetchAccounts } =
api.settings.listAccounts.useQuery(undefined, {
enabled: isAdmin,
});
// Mutations // Mutations
const updateProfileMutation = api.settings.updateProfile.useMutation({ const updateProfileMutation = api.settings.updateProfile.useMutation({
@@ -321,16 +338,6 @@ export function SettingsContent() {
toast.error(`Delete failed: ${error.message}`); toast.error(`Delete failed: ${error.message}`);
}, },
}); });
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({
onSuccess: () => {
toast.success("Account role updated");
void refetchAccounts();
},
onError: (error: { message: string }) => {
toast.error(`Failed to update role: ${error.message}`);
},
});
const handleUpdateProfile = (e: React.FormEvent) => { const handleUpdateProfile = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) { if (!name.trim()) {
@@ -449,6 +456,7 @@ export function SettingsContent() {
// Set initial name value when profile loads // Set initial name value when profile loads
React.useEffect(() => { React.useEffect(() => {
if (profile?.name && !name) { if (profile?.name && !name) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field.
setName(profile.name); setName(profile.name);
} }
if (session?.user) { if (session?.user) {
@@ -483,13 +491,10 @@ export function SettingsContent() {
]; ];
return ( return (
<Tabs defaultValue="general" className="space-y-4"> <Tabs defaultValue="general">
<TabsList <TabsList className="bg-muted/50 grid w-full grid-cols-3">
className={`bg-muted/50 grid w-full ${isAdmin ? "grid-cols-4 lg:w-[520px]" : "grid-cols-3 lg:w-[400px]"}`}
>
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="preferences">Preferences</TabsTrigger> <TabsTrigger value="preferences">Preferences</TabsTrigger>
{isAdmin && <TabsTrigger value="admin">Admin</TabsTrigger>}
<TabsTrigger value="data">Data</TabsTrigger> <TabsTrigger value="data">Data</TabsTrigger>
</TabsList> </TabsList>
@@ -729,7 +734,9 @@ export function SettingsContent() {
<Input <Input
value={brandName} value={brandName}
onChange={(event) => onChange={(event) =>
updateAppearance({ brandName: event.target.value }) updateAppearanceDebounced({
brandName: event.target.value,
})
} }
/> />
</div> </div>
@@ -739,7 +746,9 @@ export function SettingsContent() {
<Input <Input
value={brandLogoText} value={brandLogoText}
onChange={(event) => onChange={(event) =>
updateAppearance({ brandLogoText: event.target.value }) updateAppearanceDebounced({
brandLogoText: event.target.value,
})
} }
/> />
</div> </div>
@@ -749,7 +758,9 @@ export function SettingsContent() {
<Input <Input
value={brandIcon} value={brandIcon}
onChange={(event) => onChange={(event) =>
updateAppearance({ brandIcon: event.target.value }) updateAppearanceDebounced({
brandIcon: event.target.value,
})
} }
/> />
</div> </div>
@@ -759,7 +770,9 @@ export function SettingsContent() {
<Input <Input
value={brandTagline} value={brandTagline}
onChange={(event) => onChange={(event) =>
updateAppearance({ brandTagline: event.target.value }) updateAppearanceDebounced({
brandTagline: event.target.value,
})
} }
/> />
</div> </div>
@@ -826,8 +839,8 @@ export function SettingsContent() {
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground text-xs leading-snug"> <p className="text-muted-foreground text-xs leading-snug">
Applies the theme, fonts, accent, corner radius, and Applies the theme, fonts, accent, corner radius,
navigation chrome. navigation chrome, and PDF defaults.
</p> </p>
<p className="text-muted-foreground text-xs leading-snug"> <p className="text-muted-foreground text-xs leading-snug">
{ {
@@ -1013,32 +1026,25 @@ export function SettingsContent() {
</button> </button>
</div> </div>
{colorTheme === "custom" && ( {colorTheme === "custom" && (
<div className="flex flex-col gap-2 sm:flex-row"> <div className="space-y-2">
<label className="border-input bg-background hover:bg-muted flex h-10 w-full cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-xs transition-colors sm:w-40"> <InputColor
<span label="Custom Accent"
className="size-5 rounded-sm border"
style={{
backgroundColor: `hsl(${customColorValue})`,
}}
/>
Pick color
<input
type="color"
value={hslChannelsToHex(customColorValue)} value={hslChannelsToHex(customColorValue)}
onChange={(event) => onBlur={() => undefined}
updateAppearance({ onChange={(value) => {
if (isFullHexColor(value)) {
updateAppearanceDebounced({
colorTheme: "custom", colorTheme: "custom",
customColor: hexToHslChannels(event.target.value), customColor: hexToHslChannels(value),
}) });
} }
className="sr-only" }}
aria-label="Pick custom accent color" className="mt-0"
/> />
</label>
<Input <Input
value={customColorValue} value={customColorValue}
onChange={(event) => onChange={(event) =>
updateAppearance({ updateAppearanceDebounced({
colorTheme: "custom", colorTheme: "custom",
customColor: event.target.value, customColor: event.target.value,
}) })
@@ -1138,15 +1144,31 @@ export function SettingsContent() {
</div> </div>
</section> </section>
<section className="space-y-4 border-t pt-6"> {appearanceUpdating && (
<div>
<h3 className="text-sm font-medium">PDF</h3>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Controls the generated invoice PDF used for downloads and Saving appearance...
email attachments.
</p> </p>
</div> )}
<div className="grid gap-4 md:grid-cols-2"> </CardContent>
)}
</Card>
{isAdmin && (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
Invoice Settings
</CardTitle>
<CardDescription>
Configure generated invoice PDFs and preview the real document
output.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
<div className="space-y-2"> <div className="space-y-2">
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
@@ -1169,54 +1191,42 @@ export function SettingsContent() {
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground text-xs leading-snug"> <p className="text-muted-foreground text-xs leading-snug">
Minimal removes shaded table fills for a cleaner document. Minimal removes shaded table fills for a cleaner
document.
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>PDF Accent</Label> <InputColor
<div className="flex flex-col gap-2 sm:flex-row"> label="PDF Accent"
<label className="border-input bg-background hover:bg-muted flex h-10 w-full cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm shadow-xs transition-colors sm:w-40">
<span
className="size-5 rounded-sm border"
style={{ backgroundColor: pdfAccentColor }}
/>
Pick color
<input
type="color"
value={pdfAccentColor} value={pdfAccentColor}
onChange={(event) => onBlur={() => undefined}
onChange={(value) => {
if (isFullHexColor(value)) {
updateAppearance({ updateAppearance({
pdfAccentColor: event.target.value, pdfAccentColor: value,
}) });
} }
className="sr-only" }}
aria-label="Pick PDF accent color" className="mt-0"
/>
</label>
<Input
value={pdfAccentColor}
onChange={(event) =>
updateAppearance({
pdfAccentColor: event.target.value,
})
}
placeholder="#111827"
/> />
</div> </div>
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2">
<Label>Footer Text</Label> <Label>Footer Text</Label>
<Input <Input
value={pdfFooterText} value={pdfFooterText}
onChange={(event) => onChange={(event) =>
updateAppearance({ pdfFooterText: event.target.value }) updateAppearanceDebounced({
pdfFooterText: event.target.value,
})
} }
/> />
</div> </div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-3"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<div className="flex items-start justify-between gap-4 border p-3">
<div className="space-y-1"> <div className="space-y-1">
<Label>Show Logo</Label> <Label>Show Logo</Label>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
@@ -1232,7 +1242,7 @@ export function SettingsContent() {
/> />
</div> </div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-3"> <div className="flex items-start justify-between gap-4 border p-3">
<div className="space-y-1"> <div className="space-y-1">
<Label>Page Numbers</Label> <Label>Page Numbers</Label>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
@@ -1250,15 +1260,22 @@ export function SettingsContent() {
/> />
</div> </div>
</div> </div>
</section> </div>
{appearanceUpdating && (
<p className="text-muted-foreground text-xs"> <PdfPreviewFrame
Saving appearance... businessName={brandName}
</p> settings={{
)} pdfTemplate,
pdfAccentColor,
pdfFooterText,
pdfShowLogo,
pdfShowPageNumbers,
}}
/>
</div>
</CardContent> </CardContent>
)}
</Card> </Card>
)}
{/* Accessibility & Animation */} {/* Accessibility & Animation */}
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
@@ -1357,57 +1374,6 @@ export function SettingsContent() {
</Card> </Card>
</TabsContent> </TabsContent>
{isAdmin && (
<TabsContent value="admin" className="space-y-8">
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
Accounts
</CardTitle>
<CardDescription>
Manage account access and roles without opening customer data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{accounts.map((account) => (
<div
key={account.id}
className="border-border flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="text-sm font-medium">{account.name}</p>
<p className="text-muted-foreground truncate text-xs">
{account.email}
</p>
<p className="text-muted-foreground mt-1 text-xs">
Created {new Date(account.createdAt).toLocaleDateString()}
</p>
</div>
<Select
value={account.role}
onValueChange={(role) =>
updateAccountRoleMutation.mutate({
userId: account.id,
role: role as "user" | "admin",
})
}
>
<SelectTrigger className="w-full sm:w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
))}
</CardContent>
</Card>
</TabsContent>
)}
<TabsContent value="data" className="space-y-8"> <TabsContent value="data" className="space-y-8">
{/* Data Overview */} {/* Data Overview */}
<Card className="form-section bg-card border-border border"> <Card className="form-section bg-card border-border border">
-5
View File
@@ -3,7 +3,6 @@ import { HydrateClient } from "~/trpc/server";
import { PageHeader } from "~/components/layout/page-header"; import { PageHeader } from "~/components/layout/page-header";
import { DataTableSkeleton } from "~/components/data/data-table"; import { DataTableSkeleton } from "~/components/data/data-table";
import { SettingsContent } from "./_components/settings-content"; import { SettingsContent } from "./_components/settings-content";
import { Card, CardContent } from "~/components/ui/card";
export default async function SettingsPage() { export default async function SettingsPage() {
return ( return (
@@ -14,15 +13,11 @@ export default async function SettingsPage() {
variant="gradient" variant="gradient"
/> />
<Card>
<CardContent className="p-6">
<HydrateClient> <HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}> <Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
<SettingsContent /> <SettingsContent />
</Suspense> </Suspense>
</HydrateClient> </HydrateClient>
</CardContent>
</Card>
</div> </div>
); );
} }
+28 -15
View File
@@ -1,7 +1,7 @@
import "~/styles/globals.css"; import "~/styles/globals.css";
import { type Metadata } from "next"; import { type Metadata } from "next";
import { Inter, Playfair_Display, Geist_Mono } from "next/font/google"; import localFont from "next/font/local";
import { TRPCReactProvider } from "~/trpc/react"; import { TRPCReactProvider } from "~/trpc/react";
import { Toaster } from "~/components/ui/sonner"; import { Toaster } from "~/components/ui/sonner";
@@ -10,7 +10,6 @@ import { AppearanceProvider } from "~/components/providers/appearance-provider";
import { import {
brand, brand,
defaultBodyFontPreference, defaultBodyFontPreference,
defaultFontPreference,
defaultHeadingFontPreference, defaultHeadingFontPreference,
defaultInterfaceTheme, defaultInterfaceTheme,
defaultRadiusPreference, defaultRadiusPreference,
@@ -25,20 +24,37 @@ export const metadata: Metadata = {
icons: [{ rel: "icon", url: "/favicon.ico" }], icons: [{ rel: "icon", url: "/favicon.ico" }],
}; };
const inter = Inter({ const geistSans = localFont({
subsets: ["latin"], src: "../../public/fonts/geist/sans/Geist-VariableFont_wght.ttf",
variable: "--font-inter", variable: "--font-geist-sans",
display: "swap", display: "swap",
}); });
const playfair = Playfair_Display({ const playfair = localFont({
subsets: ["latin"], src: "../../node_modules/@fontsource-variable/playfair-display/files/playfair-display-latin-wght-normal.woff2",
variable: "--font-playfair", variable: "--font-playfair",
display: "swap", display: "swap",
}); });
const geistMono = Geist_Mono({ const frutiger = localFont({
subsets: ["latin"], src: [
{
path: "../../public/fonts/frutiger/Frutiger.ttf",
weight: "400",
style: "normal",
},
{
path: "../../public/fonts/frutiger/Frutiger_bold.ttf",
weight: "700",
style: "normal",
},
],
variable: "--font-frutiger",
display: "swap",
});
const geistMono = localFont({
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
variable: "--font-geist-mono", variable: "--font-geist-mono",
display: "swap", display: "swap",
}); });
@@ -51,14 +67,13 @@ export default function RootLayout({
suppressHydrationWarning suppressHydrationWarning
lang="en" lang="en"
data-interface-theme={defaultInterfaceTheme} data-interface-theme={defaultInterfaceTheme}
data-font={defaultFontPreference}
data-body-font={defaultBodyFontPreference} data-body-font={defaultBodyFontPreference}
data-heading-font={defaultHeadingFontPreference} data-heading-font={defaultHeadingFontPreference}
data-radius={defaultRadiusPreference} data-radius={defaultRadiusPreference}
data-sidebar-style={defaultSidebarStyle} data-sidebar-style={defaultSidebarStyle}
data-color-mode="system" data-color-mode="system"
data-color-theme="slate" data-color-theme="slate"
className={`${inter.variable} ${playfair.variable} ${geistMono.variable}`} className={`${geistSans.variable} ${playfair.variable} ${frutiger.variable} ${geistMono.variable}`}
> >
<head> <head>
<script <script
@@ -68,7 +83,6 @@ export default function RootLayout({
try { try {
var defaults = { var defaults = {
interfaceTheme: "${defaultInterfaceTheme}", interfaceTheme: "${defaultInterfaceTheme}",
fontPreference: "${defaultFontPreference}",
bodyFontPreference: "${defaultBodyFontPreference}", bodyFontPreference: "${defaultBodyFontPreference}",
headingFontPreference: "${defaultHeadingFontPreference}", headingFontPreference: "${defaultHeadingFontPreference}",
radiusPreference: "${defaultRadiusPreference}", radiusPreference: "${defaultRadiusPreference}",
@@ -80,9 +94,8 @@ export default function RootLayout({
var appearance = Object.assign(defaults, stored); var appearance = Object.assign(defaults, stored);
var root = document.documentElement; var root = document.documentElement;
root.dataset.interfaceTheme = appearance.interfaceTheme; root.dataset.interfaceTheme = appearance.interfaceTheme;
root.dataset.font = appearance.fontPreference; root.dataset.bodyFont = appearance.bodyFontPreference;
root.dataset.bodyFont = appearance.bodyFontPreference || appearance.fontPreference; root.dataset.headingFont = appearance.headingFontPreference;
root.dataset.headingFont = appearance.headingFontPreference || appearance.fontPreference;
root.dataset.radius = appearance.radiusPreference; root.dataset.radius = appearance.radiusPreference;
root.dataset.sidebarStyle = appearance.sidebarStyle; root.dataset.sidebarStyle = appearance.sidebarStyle;
root.dataset.colorMode = appearance.colorMode; root.dataset.colorMode = appearance.colorMode;
+69 -239
View File
@@ -1,278 +1,108 @@
import Link from "next/link"; import Link from "next/link";
import { Button } from "~/components/ui/button"; import { ArrowRight, FileText, UserRound } from "lucide-react";
import { AuthRedirect } from "~/components/AuthRedirect"; import { AuthRedirect } from "~/components/AuthRedirect";
import { Card, CardContent } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Logo } from "~/components/branding/logo"; import { Logo } from "~/components/branding/logo";
import { import { Button } from "~/components/ui/button";
ArrowRight, import { env } from "~/env";
Check,
Zap,
Shield,
BarChart3,
Rocket,
} from "lucide-react";
import { brand } from "~/lib/branding"; import { brand } from "~/lib/branding";
export default function HomePage() { export default function HomePage() {
const allowRegistration = env.DISABLE_SIGNUPS !== true;
return ( return (
<div className="relative min-h-screen overflow-x-hidden"> <main className="bg-background text-foreground min-h-screen">
<AuthRedirect /> <AuthRedirect />
{/* Blob Background for Homepage */} <div className="mx-auto flex min-h-screen w-full max-w-5xl flex-col px-5 py-5 sm:px-6 lg:px-8">
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden"> <header className="flex items-center justify-between gap-4 border-b py-4">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div> <Logo animated={false} />
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div> <nav className="flex items-center gap-2">
</div>
{/* Navigation */}
<nav className="border-border/60 bg-background/80 fixed top-4 right-4 left-4 z-50 m-4 rounded-2xl border backdrop-blur-md">
<div className="mx-auto px-6">
<div className="flex h-16 items-center justify-between">
<Logo />
<div className="hidden items-center space-x-8 md:flex">
<a
href="#features"
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
>
Features
</a>
<a
href="#pricing"
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
>
Pricing
</a>
</div>
<div className="flex items-center space-x-4">
<Link href="/auth/signin"> <Link href="/auth/signin">
<Button <Button variant="ghost" size="sm">
variant="ghost" Sign in
size="sm"
className="text-muted-foreground hover:text-foreground"
>
Sign In
</Button> </Button>
</Link> </Link>
{allowRegistration && (
<Link href="/auth/register"> <Link href="/auth/register">
<Button size="sm" variant="default" className="rounded-xl px-6"> <Button size="sm">Create account</Button>
Get Started
</Button>
</Link> </Link>
</div> )}
</div>
</div>
</nav> </nav>
</header>
{/* Hero Section */} <section className="grid flex-1 items-center gap-10 py-14 md:grid-cols-[1fr_320px] md:py-20">
<section className="relative pt-48 pb-32"> <div className="max-w-2xl space-y-7">
<div className="container mx-auto px-4 text-center"> <div className="space-y-4">
<div className="mx-auto max-w-4xl"> <p className="text-muted-foreground text-sm font-medium">
<Badge className="bg-primary/10 text-primary border-primary/20 mb-8 rounded-full border px-4 py-1 text-sm"> Personal invoicing
<Zap className="mr-2 h-3.5 w-3.5" /> </p>
Completely Free for Everyone <h1 className="font-heading text-4xl leading-tight font-bold tracking-normal sm:text-5xl">
</Badge> {brand.name} is a place to make and track invoices.
<h1 className="text-foreground font-heading mb-8 text-6xl leading-tight font-bold tracking-tight sm:text-7xl lg:text-8xl">
{brand.name} <br />
<span className="text-primary italic">Beautifully Simple.</span>
</h1> </h1>
<p className="text-muted-foreground max-w-xl text-base leading-7 sm:text-lg">
<p className="text-muted-foreground mx-auto mb-12 max-w-2xl font-sans text-xl leading-relaxed"> Built for one person managing real clients, real work, and the
{brand.tagline} small admin loop around getting paid.
</p> </p>
</div>
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center"> <div className="flex flex-col gap-3 sm:flex-row">
<Link href="/auth/signin">
<Button size="lg" className="h-11 px-5">
Open workspace
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
{allowRegistration && (
<Link href="/auth/register"> <Link href="/auth/register">
<Button <Button variant="outline" size="lg" className="h-11 px-5">
size="lg" Create account
className="shadow-primary/20 hover:shadow-primary/30 h-14 rounded-2xl px-10 text-lg shadow-xl transition-all duration-300 hover:shadow-2xl"
>
Start For Free
<ArrowRight className="ml-2 h-5 w-5" />
</Button> </Button>
</Link> </Link>
<a href="#features"> )}
<Button </div>
variant="outline"
size="lg"
className="border-border/50 bg-background/50 hover:bg-background/80 h-14 rounded-2xl px-10 text-lg backdrop-blur-sm"
>
Learn More
</Button>
</a>
</div> </div>
<div className="text-muted-foreground/80 mt-16 flex flex-col items-center justify-center gap-2 text-sm sm:flex-row sm:gap-8"> <div className="border-border bg-card text-card-foreground rounded-xl border p-5 shadow-sm">
<div className="flex items-center gap-2"> <div className="space-y-5">
<Check className="text-primary h-4 w-4" /> <div className="flex items-start gap-3">
<span>No credit card required</span> <div className="bg-primary/10 text-primary rounded-md p-2">
<UserRound className="h-4 w-4" />
</div> </div>
<div className="flex items-center gap-2"> <div>
<Check className="text-primary h-4 w-4" /> <h2 className="text-sm font-semibold">Clients</h2>
<span>Setup in 2 minutes</span> <p className="text-muted-foreground mt-1 text-sm leading-6">
Keep the people and businesses you invoice in one place.
</p>
</div> </div>
<div className="flex items-center gap-2"> </div>
<Check className="text-primary h-4 w-4" />
<span>Free forever</span> <div className="flex items-start gap-3 border-t pt-5">
<div className="bg-primary/10 text-primary rounded-md p-2">
<FileText className="h-4 w-4" />
</div>
<div>
<h2 className="text-sm font-semibold">Invoices</h2>
<p className="text-muted-foreground mt-1 text-sm leading-6">
Draft, send, mark paid, and export the PDF when you need it.
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* Features Section */} <footer className="text-muted-foreground flex flex-col gap-3 border-t py-5 text-sm sm:flex-row sm:items-center sm:justify-between">
<section id="features" className="relative py-24"> <span>© 2026 {brand.name}</span>
<div className="relative z-10 container mx-auto px-4"> <div className="flex gap-5">
<div className="mb-20 text-center"> <Link href="/privacy" className="hover:text-foreground">
<h2 className="text-foreground font-heading mb-6 text-4xl font-bold sm:text-5xl">
Everything you need to{" "}
<span className="text-primary italic">thrive</span>
</h2>
<p className="text-muted-foreground mx-auto max-w-2xl text-lg">
Powerful features wrapped in a calm, focused interface.
</p>
</div>
<div className="grid gap-8 md:grid-cols-3">
{[
{
icon: Rocket,
title: "Quick Setup",
description:
"Start creating invoices immediately. No complicated setup required.",
items: [
"Simple client management",
"Professional templates",
"Easy invoice sending",
],
},
{
icon: BarChart3,
title: "Payment Tracking",
description:
"Keep track of invoice status and monitor your payments effortlessly.",
items: [
"Invoice status tracking",
"Payment history",
"Overdue notifications",
],
},
{
icon: Shield,
title: "Professional Features",
description:
"Tools that make you look professional and get you paid faster.",
items: [
"PDF generation",
"Custom tax rates",
"Professional numbering",
],
},
].map((feature, i) => (
<Card
key={i}
className="group border-border/40 bg-background/60 backdrop-blur-xl transition-transform duration-500 hover:-translate-y-2"
>
<CardContent className="p-8">
<div className="bg-primary/10 text-primary mb-6 inline-flex rounded-2xl p-4">
<feature.icon className="h-8 w-8" />
</div>
<h3 className="text-foreground font-heading mb-4 text-2xl font-bold">
{feature.title}
</h3>
<p className="text-muted-foreground mb-6 leading-relaxed">
{feature.description}
</p>
<ul className="space-y-3">
{feature.items.map((item, j) => (
<li
key={j}
className="text-foreground/80 flex items-center gap-3 text-sm"
>
<div className="bg-primary h-1.5 w-1.5 rounded-full" />
{item}
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
</div>
</section>
{/* Pricing Section */}
<section id="pricing" className="relative overflow-hidden py-24">
<div className="relative z-10 container mx-auto px-4">
<div className="mx-auto mb-16 max-w-4xl text-center">
<h2 className="font-heading mb-6 text-5xl font-bold">
Simple Pricing
</h2>
<p className="text-muted-foreground text-xl">
Focus on your work, not on fees.
</p>
</div>
<div className="mx-auto max-w-md">
<Card className="border-primary/50 shadow-primary/5 bg-background/80 relative overflow-visible shadow-2xl backdrop-blur-xl">
<div className="bg-primary text-primary-foreground absolute -top-4 left-1/2 -translate-x-1/2 rounded-full px-6 py-1.5 text-sm font-medium shadow-lg">
Forever Free
</div>
<CardContent className="p-10 text-center">
<div className="font-heading mb-2 text-6xl font-bold">$0</div>
<div className="text-muted-foreground mb-8">
No credit card required.
</div>
<div className="mb-10 space-y-4 pl-8 text-left">
{[
"Unlimited Invoices",
"Unlimited Clients",
"PDF Downloads",
"Payment Tracking",
"Email Support",
].map((item, i) => (
<div key={i} className="flex items-center gap-3">
<Check className="text-primary h-5 w-5 shrink-0" />
<span className="text-foreground/90">{item}</span>
</div>
))}
</div>
<Link href="/auth/register" className="block">
<Button size="lg" className="h-12 w-full rounded-xl text-lg">
Get Started
</Button>
</Link>
</CardContent>
</Card>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-border/40 bg-background/50 mt-12 border-t py-12 backdrop-blur-sm">
<div className="container mx-auto flex flex-col items-center justify-between gap-6 px-6 md:flex-row">
<div className="flex items-center gap-3">
<Logo size="sm" />
<span className="text-muted-foreground text-sm">
© 2024 beenvoice
</span>
</div>
<div className="text-muted-foreground flex gap-8 text-sm">
<a href="#" className="hover:text-foreground transition-colors">
Privacy Privacy
</a> </Link>
<a href="#" className="hover:text-foreground transition-colors"> <Link href="/terms" className="hover:text-foreground">
Terms Terms
</a> </Link>
<a href="#" className="hover:text-foreground transition-colors">
Contact
</a>
</div>
</div> </div>
</footer> </footer>
</div> </div>
</main>
); );
} }
@@ -64,7 +64,7 @@ export function AddressAutocomplete({
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
/> />
{showSuggestions && suggestions.length > 0 && ( {showSuggestions && suggestions.length > 0 && (
<Card className="bg-card border-border border absolute z-10 mt-1 max-h-60 w-full overflow-auto"> <Card className="bg-card border-border absolute z-10 mt-1 max-h-60 w-full overflow-auto border">
<ul> <ul>
{suggestions.map((s) => ( {suggestions.map((s) => (
<li <li
+25 -10
View File
@@ -11,10 +11,24 @@ interface LogoProps {
animated?: boolean; animated?: boolean;
} }
function splitLogoText(logoText: string) {
const voiceIndex = logoText.toLowerCase().indexOf("voice");
if (voiceIndex > 0) {
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
}
return [
logoText.slice(0, Math.ceil(logoText.length / 2)),
logoText.slice(Math.ceil(logoText.length / 2)),
] as const;
}
export function Logo({ className, size = "md", animated = true }: LogoProps) { export function Logo({ className, size = "md", animated = true }: LogoProps) {
const appearance = useAppearance(); const appearance = useAppearance();
const logoText = appearance.brandLogoText || brand.logoText; const logoText = appearance.brandLogoText || brand.logoText;
const icon = appearance.brandIcon || brand.icon; const icon = appearance.brandIcon || brand.icon;
const [logoPrefix, logoSuffix] = splitLogoText(logoText);
const sizeClasses = { const sizeClasses = {
sm: "text-base", sm: "text-base",
md: "text-xl", md: "text-xl",
@@ -29,7 +43,8 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
className={className} className={className}
size={size} size={size}
sizeClasses={sizeClasses} sizeClasses={sizeClasses}
logoText={logoText} logoPrefix={logoPrefix}
logoSuffix={logoSuffix}
icon={icon} icon={icon}
/> />
); );
@@ -68,7 +83,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.04, duration: 0.05, ease: "easeOut" }} transition={{ delay: 0.04, duration: 0.05, ease: "easeOut" }}
className="text-foreground font-bold tracking-tight" className="text-foreground font-bold tracking-tight"
> >
{logoText.slice(0, Math.ceil(logoText.length / 2))} {logoPrefix}
</motion.span> </motion.span>
<motion.span <motion.span
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -76,7 +91,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.06, duration: 0.05, ease: "easeOut" }} transition={{ delay: 0.06, duration: 0.05, ease: "easeOut" }}
className="text-foreground/70 font-bold tracking-tight" className="text-foreground/70 font-bold tracking-tight"
> >
{logoText.slice(Math.ceil(logoText.length / 2))} {logoSuffix}
</motion.span> </motion.span>
</> </>
)} )}
@@ -88,13 +103,15 @@ function LogoContent({
className, className,
size, size,
sizeClasses, sizeClasses,
logoText, logoPrefix,
logoSuffix,
icon, icon,
}: { }: {
className?: string; className?: string;
size: "sm" | "md" | "lg" | "xl" | "icon"; size: "sm" | "md" | "lg" | "xl" | "icon";
sizeClasses: Record<string, string>; sizeClasses: Record<string, string>;
logoText: string; logoPrefix: string;
logoSuffix: string;
icon: string; icon: string;
}) { }) {
return ( return (
@@ -105,17 +122,15 @@ function LogoContent({
className, className,
)} )}
> >
<span className="text-primary font-bold tracking-tight"> <span className="text-primary font-bold tracking-tight">{icon}</span>
{icon}
</span>
{size !== "icon" && ( {size !== "icon" && (
<> <>
<span className="inline-block w-1"></span> <span className="inline-block w-1"></span>
<span className="text-foreground font-bold tracking-tight"> <span className="text-foreground font-bold tracking-tight">
{logoText.slice(0, Math.ceil(logoText.length / 2))} {logoPrefix}
</span> </span>
<span className="text-foreground/70 font-bold tracking-tight"> <span className="text-foreground/70 font-bold tracking-tight">
{logoText.slice(Math.ceil(logoText.length / 2))} {logoSuffix}
</span> </span>
</> </>
)} )}
+2 -5
View File
@@ -556,10 +556,7 @@ export function CSVImportPage() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{files.map((fileData, index) => ( {files.map((fileData, index) => (
<div <div key={index} className="border-border bg-card border p-4">
key={index}
className="border-border bg-card border p-4"
>
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<FileText className="text-primary h-5 w-5" /> <FileText className="text-primary h-5 w-5" />
@@ -772,7 +769,7 @@ export function CSVImportPage() {
{/* Preview Modal */} {/* Preview Modal */}
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}> <Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
<DialogContent className="bg-card border-border border flex max-h-[90vh] max-w-4xl flex-col"> <DialogContent className="bg-card border-border flex max-h-[90vh] max-w-4xl flex-col border">
<DialogHeader className="flex-shrink-0"> <DialogHeader className="flex-shrink-0">
<DialogTitle className="text-foreground flex items-center gap-2 text-xl font-bold"> <DialogTitle className="text-foreground flex items-center gap-2 text-xl font-bold">
<FileText className="text-primary h-5 w-5" /> <FileText className="text-primary h-5 w-5" />
+18 -25
View File
@@ -3,6 +3,7 @@
import type { import type {
ColumnDef, ColumnDef,
ColumnFiltersState, ColumnFiltersState,
RowData,
SortingState, SortingState,
VisibilityState, VisibilityState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
@@ -53,6 +54,14 @@ import {
} from "~/components/ui/table"; } from "~/components/ui/table";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Generic names must match TanStack's declaration for module augmentation.
interface ColumnMeta<TData extends RowData, TValue> {
headerClassName?: string;
cellClassName?: string;
}
}
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
data: TData[]; data: TData[];
@@ -125,23 +134,9 @@ export function DataTable<TData, TValue>({
...column, ...column,
// Add a meta property to control responsive visibility // Add a meta property to control responsive visibility
meta: { meta: {
...(( ...(column.meta ?? {}),
column as ColumnDef<TData, TValue> & { headerClassName: column.meta?.headerClassName ?? "",
meta?: { headerClassName?: string; cellClassName?: string }; cellClassName: column.meta?.cellClassName ?? "",
}
).meta ?? {}),
headerClassName:
(
column as ColumnDef<TData, TValue> & {
meta?: { headerClassName?: string; cellClassName?: string };
}
).meta?.headerClassName ?? "",
cellClassName:
(
column as ColumnDef<TData, TValue> & {
meta?: { headerClassName?: string; cellClassName?: string };
}
).meta?.cellClassName ?? "",
}, },
})); }));
}, [columns]); }, [columns]);
@@ -369,9 +364,7 @@ export function DataTable<TData, TValue>({
className="bg-muted/50 hover:bg-muted/50" className="bg-muted/50 hover:bg-muted/50"
> >
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as const meta = header.column.columnDef.meta;
| { headerClassName?: string; cellClassName?: string }
| undefined;
return ( return (
<TableHead <TableHead
key={header.id} key={header.id}
@@ -407,9 +400,7 @@ export function DataTable<TData, TValue>({
} }
> >
{row.getVisibleCells().map((cell) => { {row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as const meta = cell.column.columnDef.meta;
| { headerClassName?: string; cellClassName?: string }
| undefined;
return ( return (
<TableCell <TableCell
key={cell.id} key={cell.id}
@@ -451,7 +442,8 @@ export function DataTable<TData, TValue>({
<p className="text-muted-foreground hidden text-xs sm:inline sm:text-sm"> <p className="text-muted-foreground hidden text-xs sm:inline sm:text-sm">
{table.getFilteredRowModel().rows.length === 0 {table.getFilteredRowModel().rows.length === 0
? "No entries" ? "No entries"
: `Showing ${table.getState().pagination.pageIndex * : `Showing ${
table.getState().pagination.pageIndex *
table.getState().pagination.pageSize + table.getState().pagination.pageSize +
1 1
} to ${Math.min( } to ${Math.min(
@@ -463,7 +455,8 @@ export function DataTable<TData, TValue>({
<p className="text-muted-foreground text-xs sm:hidden"> <p className="text-muted-foreground text-xs sm:hidden">
{table.getFilteredRowModel().rows.length === 0 {table.getFilteredRowModel().rows.length === 0
? "0" ? "0"
: `${table.getState().pagination.pageIndex * : `${
table.getState().pagination.pageIndex *
table.getState().pagination.pageSize + table.getState().pagination.pageSize +
1 1
}-${Math.min( }-${Math.min(
@@ -87,7 +87,8 @@ function SortableItem({
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={`card-secondary transition-colors ${isDragging ? "opacity-50 shadow-lg" : "" className={`card-secondary transition-colors ${
isDragging ? "opacity-50 shadow-lg" : ""
}`} }`}
> >
{/* Desktop Layout - Hidden on Mobile */} {/* Desktop Layout - Hidden on Mobile */}
@@ -360,10 +361,7 @@ export function EditableInvoiceItems({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{items.map((item, _index) => ( {items.map((item, _index) => (
<div <div key={item.id} className="card-secondary animate-pulse p-4">
key={item.id}
className="card-secondary animate-pulse p-4"
>
{/* Desktop Skeleton */} {/* Desktop Skeleton */}
<div className="hidden grid-cols-12 gap-3 md:grid"> <div className="hidden grid-cols-12 gap-3 md:grid">
<div className="col-span-1"> <div className="col-span-1">
-516
View File
@@ -1,516 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
import { Separator } from "~/components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { toast } from "sonner";
import { format } from "date-fns";
import {
FileText,
User,
DollarSign,
Trash2,
Download,
Send,
Clock,
MapPin,
Mail,
Phone,
AlertCircle,
} from "lucide-react";
import Link from "next/link";
import { generateInvoicePDF } from "~/lib/pdf-export";
import { Skeleton } from "~/components/ui/skeleton";
interface InvoiceViewProps {
invoiceId: string;
}
const statusIconConfig = {
draft: FileText,
sent: Send,
paid: DollarSign,
overdue: AlertCircle,
} as const;
export function InvoiceView({ invoiceId }: InvoiceViewProps) {
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isExportingPDF, setIsExportingPDF] = useState(false);
// Fetch invoice data
const {
data: invoice,
isLoading,
refetch,
} = api.invoices.getById.useQuery({ id: invoiceId });
// Delete mutation
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
toast.success("Invoice deleted successfully");
setDeleteDialogOpen(false);
router.push("/dashboard/invoices");
},
onError: (error) => {
toast.error(error.message ?? "Failed to delete invoice");
},
});
// Update status mutation
const updateStatus = api.invoices.updateStatus.useMutation({
onSuccess: () => {
toast.success("Status updated successfully");
void refetch();
},
onError: (error) => {
toast.error(error.message ?? "Failed to update status");
},
});
const handleDelete = () => {
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
deleteInvoice.mutate({ id: invoiceId });
};
const handleStatusUpdate = (newStatus: "draft" | "sent" | "paid") => {
updateStatus.mutate({ id: invoiceId, status: newStatus });
};
const handlePDFExport = async () => {
if (!invoice) return;
setIsExportingPDF(true);
try {
await generateInvoicePDF(invoice);
toast.success("PDF exported successfully");
} catch (error) {
console.error("PDF export error:", error);
toast.error("Failed to export PDF. Please try again.");
} finally {
setIsExportingPDF(false);
}
};
const formatCurrency = (amount: number, currency = "USD") => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
};
const formatDate = (date: Date) => {
return format(new Date(date), "MMM dd, yyyy");
};
const isOverdue =
invoice &&
new Date(invoice.dueDate) < new Date() &&
invoice.status !== "paid";
if (isLoading) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<div className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</CardContent>
</Card>
</div>
);
}
if (!invoice) {
return (
<div className="py-12 text-center">
<FileText className="text-muted mx-auto mb-4 h-12 w-12" />
<h3 className="text-foreground mb-2 text-lg font-medium">
Invoice not found
</h3>
<p className="text-muted mb-4">
The invoice you&apos;re looking for doesn&apos;t exist or has been
deleted.
</p>
<Button asChild>
<Link href="/dashboard/invoices">Back to Invoices</Link>
</Button>
</div>
);
}
const StatusIcon =
statusIconConfig[invoice.status as keyof typeof statusIconConfig];
return (
<div className="space-y-6">
{/* Status Alert */}
{isOverdue && (
<Card className="border-destructive/20 bg-destructive/10">
<CardContent className="p-4">
<div className="text-destructive flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
<span className="font-medium">This invoice is overdue</span>
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 lg:col-span-2">
{/* Invoice Header Card */}
<Card className="bg-card border-border border">
<CardContent>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1 space-y-4">
<div className="flex items-center gap-3">
<div className="bg-primary/10 flex-shrink-0 p-2">
<FileText className="text-primary h-6 w-6" />
</div>
<div className="min-w-0">
<h2 className="text-foreground truncate text-2xl font-bold">
{invoice.invoiceNumber}
</h2>
<p className="text-muted-foreground">
Professional Invoice
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6 text-sm">
<div>
<span className="text-muted-foreground">Issue Date</span>
<p className="text-foreground font-medium">
{formatDate(invoice.issueDate)}
</p>
</div>
<div>
<span className="text-muted-foreground">Due Date</span>
<p className="text-foreground font-medium">
{formatDate(invoice.dueDate)}
</p>
</div>
</div>
</div>
<div className="flex flex-row items-center justify-between gap-3 sm:flex-col sm:items-end sm:text-right">
<div>
<StatusBadge
status={invoice.status as StatusType}
className="px-3 py-1 text-sm font-medium"
>
<StatusIcon className="mr-1 h-3 w-3" />
</StatusBadge>
<div className="text-primary mt-1 text-2xl font-bold sm:text-3xl">
{formatCurrency(invoice.totalAmount)}
</div>
</div>
<Button
onClick={handlePDFExport}
disabled={isExportingPDF}
variant="default"
className="flex-shrink-0 transform-none"
>
{isExportingPDF ? (
<>
<div className="mr-2 h-4 w-4 animate-spin border-2 border-white border-t-transparent" />
Generating PDF...
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Download PDF
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Client Information */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-primary flex items-center gap-2">
<User className="h-5 w-5" />
Bill To
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="text-foreground text-lg font-semibold">
{invoice.client?.name}
</h3>
</div>
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
{invoice.client?.email && (
<div className="text-muted-foreground flex items-center gap-2">
<Mail className="text-muted-foreground h-4 w-4" />
{invoice.client.email}
</div>
)}
{invoice.client?.phone && (
<div className="text-muted-foreground flex items-center gap-2">
<Phone className="text-muted-foreground h-4 w-4" />
{invoice.client.phone}
</div>
)}
{(invoice.client?.addressLine1 ??
invoice.client?.city ??
invoice.client?.state) && (
<div className="text-muted-foreground flex items-start gap-2 md:col-span-2">
<MapPin className="text-muted-foreground mt-0.5 h-4 w-4 flex-shrink-0" />
<div>
{invoice.client?.addressLine1 && (
<div>{invoice.client.addressLine1}</div>
)}
{invoice.client?.addressLine2 && (
<div>{invoice.client.addressLine2}</div>
)}
{(invoice.client?.city ??
invoice.client?.state ??
invoice.client?.postalCode) && (
<div>
{[
invoice.client?.city,
invoice.client?.state,
invoice.client?.postalCode,
]
.filter(Boolean)
.join(", ")}
</div>
)}
{invoice.client?.country && (
<div>{invoice.client.country}</div>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Invoice Items */}
<Card className="bg-secondary border-border border">
<CardHeader>
<CardTitle className="text-primary flex items-center gap-2">
<Clock className="h-5 w-5" />
Invoice Items
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{invoice.items?.map((item, index) => (
<div
key={item.id || index}
className="bg-background flex flex-col gap-1 rounded-lg p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 flex-1">
<div className="text-foreground font-medium break-words">
{item.description}
</div>
<div className="text-muted-foreground mt-0.5 text-sm">
{formatDate(item.date)} &middot; {item.hours}h @{" "}
{formatCurrency(item.rate)}/hr
</div>
</div>
<div className="text-foreground flex-shrink-0 font-medium sm:text-right">
{formatCurrency(item.amount)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Notes */}
{invoice.notes && (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-primary">Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground whitespace-pre-wrap">
{invoice.notes}
</p>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Status Actions */}
<Card className="bg-secondary border-border border">
<CardHeader>
<CardTitle className="text-primary">Status Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{invoice.status === "draft" && (
<Button
onClick={() => handleStatusUpdate("sent")}
disabled={updateStatus.isPending}
className="w-full"
>
<Send className="mr-2 h-4 w-4" />
Mark as Sent
</Button>
)}
{invoice.status === "sent" && (
<Button
onClick={() => handleStatusUpdate("paid")}
disabled={updateStatus.isPending}
className="w-full"
>
<DollarSign className="mr-2 h-4 w-4" />
Mark as Paid
</Button>
)}
{invoice.status === "overdue" && (
<Button
onClick={() => handleStatusUpdate("paid")}
disabled={updateStatus.isPending}
className="w-full"
>
<DollarSign className="mr-2 h-4 w-4" />
Mark as Paid
</Button>
)}
{invoice.status === "paid" && (
<div className="py-4 text-center">
<DollarSign className="text-primary mx-auto mb-2 h-8 w-8" />
<p className="text-primary font-medium">Invoice Paid</p>
</div>
)}
</CardContent>
</Card>
{/* Invoice Summary */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-primary">Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal</span>
<span className="text-foreground font-medium">
{formatCurrency(invoice.totalAmount, invoice.currency)}
</span>
</div>
{(invoice.taxRate ?? 0) > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Tax ({((invoice.taxRate ?? 0) * 100).toFixed(1)}%)
</span>
<span className="text-foreground font-medium">
{formatCurrency(
invoice.totalAmount * (invoice.taxRate ?? 0),
invoice.currency,
)}
</span>
</div>
)}
<Separator />
<div className="flex justify-between text-lg font-bold">
<span className="text-foreground">Total</span>
<span className="text-primary">
{formatCurrency(
invoice.totalAmount * (1 + (invoice.taxRate ?? 0)),
invoice.currency,
)}
</span>
</div>
</div>
<div className="border-border border-t pt-4 text-center">
<p className="text-muted-foreground text-sm">
{invoice.items?.length ?? 0} item
{invoice.items?.length !== 1 ? "s" : ""}
</p>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="bg-card border-destructive/20 border">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
</CardHeader>
<CardContent>
<Button
onClick={handleDelete}
variant="destructive"
className="w-full"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Invoice
</Button>
</CardContent>
</Card>
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="bg-card border-border border">
<DialogHeader>
<DialogTitle className="text-foreground text-xl font-bold">
Delete Invoice
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Are you sure you want to delete this invoice? This action cannot
be undone and will permanently remove the invoice and all its
data.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={deleteInvoice.isPending}
className="bg-destructive hover:bg-destructive/90"
>
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+1
View File
@@ -143,6 +143,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Load business data when editing // Load business data when editing
useEffect(() => { useEffect(() => {
if (business && mode === "edit") { if (business && mode === "edit") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
setFormData({ setFormData({
name: business.name, name: business.name,
nickname: business.nickname ?? "", nickname: business.nickname ?? "",
+1
View File
@@ -119,6 +119,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
// Load client data when editing // Load client data when editing
useEffect(() => { useEffect(() => {
if (client && mode === "edit") { if (client && mode === "edit") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form.
setFormData({ setFormData({
name: client.name, name: client.name,
email: client.email ?? "", email: client.email ?? "",
+5 -6
View File
@@ -96,7 +96,7 @@ export function EmailComposer({
content: customMessage, content: customMessage,
immediatelyRender: false, immediatelyRender: false,
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
onCustomMessageChange?.(editor.getHTML()); onCustomMessageChange?.(editor.isEmpty ? "" : editor.getHTML());
}, },
editorProps: { editorProps: {
attributes: { attributes: {
@@ -109,7 +109,7 @@ export function EmailComposer({
// Update editor content when customMessage prop changes // Update editor content when customMessage prop changes
useEffect(() => { useEffect(() => {
if (editor && customMessage !== undefined) { if (editor && customMessage !== undefined) {
const currentContent = editor.getHTML(); const currentContent = editor.isEmpty ? "" : editor.getHTML();
if (currentContent !== customMessage) { if (currentContent !== customMessage) {
editor.commands.setContent(customMessage); editor.commands.setContent(customMessage);
} }
@@ -222,11 +222,10 @@ export function EmailComposer({
{onCustomMessageChange && ( {onCustomMessageChange && (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label className="text-sm font-medium"> <Label className="text-sm font-medium">Email Note (Optional)</Label>
Custom Message (Optional)
</Label>
<p className="text-muted-foreground mb-2 text-xs"> <p className="text-muted-foreground mb-2 text-xs">
This message will appear between the greeting and invoice summary This appears only in the email body and is not added to the
invoice PDF.
</p> </p>
</div> </div>
+8 -4
View File
@@ -17,6 +17,7 @@ interface EmailPreviewProps {
taxRate: number; taxRate: number;
status?: string; status?: string;
totalAmount?: number; totalAmount?: number;
currency?: string | null;
client?: { client?: {
name: string; name: string;
email: string | null; email: string | null;
@@ -27,8 +28,11 @@ interface EmailPreviewProps {
}; };
items?: Array<{ items?: Array<{
id: string; id: string;
date?: Date;
description?: string;
hours: number; hours: number;
rate: number; rate: number;
amount?: number;
}>; }>;
}; };
className?: string; className?: string;
@@ -66,7 +70,7 @@ export function EmailPreview({
status: invoice.status ?? "draft", status: invoice.status ?? "draft",
totalAmount: invoice.totalAmount ?? calculateTotal(), totalAmount: invoice.totalAmount ?? calculateTotal(),
taxRate: invoice.taxRate, taxRate: invoice.taxRate,
notes: null, currency: invoice.currency,
client: { client: {
name: invoice.client?.name ?? "Client", name: invoice.client?.name ?? "Client",
email: invoice.client?.email ?? null, email: invoice.client?.email ?? null,
@@ -74,11 +78,11 @@ export function EmailPreview({
business: invoice.business ?? null, business: invoice.business ?? null,
items: items:
invoice.items?.map((item) => ({ invoice.items?.map((item) => ({
date: new Date(), date: item.date ?? new Date(),
description: "Service", description: item.description ?? "Service",
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.hours * item.rate, amount: item.amount ?? item.hours * item.rate,
})) ?? [], })) ?? [],
}, },
customContent: content, customContent: content,
+199 -77
View File
@@ -1,7 +1,17 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { format, startOfWeek, endOfWeek, eachDayOfInterval, isSameDay, subWeeks, addWeeks, subMonths, addMonths } from "date-fns"; import {
format,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameDay,
subWeeks,
addWeeks,
subMonths,
addMonths,
} from "date-fns";
import { Calendar } from "~/components/ui/calendar"; import { Calendar } from "~/components/ui/calendar";
import { import {
Sheet, Sheet,
@@ -14,10 +24,16 @@ import { Button } from "~/components/ui/button";
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 { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
import { Plus, Trash2, Clock, Calendar as CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"; import {
Plus,
Trash2,
Clock,
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
interface InvoiceItem { interface InvoiceItem {
id: string; id: string;
date: Date; date: Date;
@@ -32,7 +48,7 @@ interface InvoiceCalendarViewProps {
onUpdateItem: ( onUpdateItem: (
index: number, index: number,
field: string, field: string,
value: string | number | Date value: string | number | Date,
) => void; ) => void;
onAddItem: (date?: Date) => void; onAddItem: (date?: Date) => void;
onRemoveItem: (index: number) => void; onRemoveItem: (index: number) => void;
@@ -64,14 +80,17 @@ export function InvoiceCalendarView({
}, [items, date]); }, [items, date]);
// Helper to get items for any date (for calendar view) // Helper to get items for any date (for calendar view)
const getItemsForDate = React.useCallback((targetDate: Date) => { const getItemsForDate = React.useCallback(
(targetDate: Date) => {
return items return items
.map((item, index) => ({ item, index })) .map((item, index) => ({ item, index }))
.filter((wrapper) => { .filter((wrapper) => {
const itemDate = new Date(wrapper.item.date); const itemDate = new Date(wrapper.item.date);
return isSameDay(itemDate, targetDate); return isSameDay(itemDate, targetDate);
}); });
}, [items]); },
[items],
);
const handleSelectDate = (newDate: Date | undefined) => { const handleSelectDate = (newDate: Date | undefined) => {
if (!newDate) return; if (!newDate) return;
@@ -88,7 +107,10 @@ export function InvoiceCalendarView({
// Week View Logic - Uses viewDate // Week View Logic - Uses viewDate
const currentWeekStart = startOfWeek(viewDate); const currentWeekStart = startOfWeek(viewDate);
const currentWeekEnd = endOfWeek(viewDate); const currentWeekEnd = endOfWeek(viewDate);
const weekDays = eachDayOfInterval({ start: currentWeekStart, end: currentWeekEnd }); const weekDays = eachDayOfInterval({
start: currentWeekStart,
end: currentWeekEnd,
});
const handleCloseSheet = (isOpen: boolean) => { const handleCloseSheet = (isOpen: boolean) => {
setSheetOpen(isOpen); setSheetOpen(isOpen);
@@ -98,51 +120,81 @@ export function InvoiceCalendarView({
}; };
return ( return (
<div className={cn("flex flex-col gap-4 h-full w-full", className)}> <div className={cn("flex h-full w-full flex-col gap-4", className)}>
<div className="flex items-center justify-between px-4 pt-4 w-full gap-4"> <div className="flex w-full items-center justify-between gap-4 px-4 pt-4">
{/* Navigation Controls */} {/* Navigation Controls */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{view === "week" ? ( {view === "week" ? (
<> <>
<Button variant="outline" size="icon" onClick={() => setViewDate(d => subWeeks(d, 1))} className="h-8 w-8 rounded-lg"> <Button
variant="outline"
size="icon"
onClick={() => setViewDate((d) => subWeeks(d, 1))}
className="h-8 w-8 rounded-lg"
>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<span className="text-sm font-medium w-36 text-center"> <span className="w-36 text-center text-sm font-medium">
{`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`} {`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`}
</span> </span>
<Button variant="outline" size="icon" onClick={() => setViewDate(d => addWeeks(d, 1))} className="h-8 w-8 rounded-lg"> <Button
variant="outline"
size="icon"
onClick={() => setViewDate((d) => addWeeks(d, 1))}
className="h-8 w-8 rounded-lg"
>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</> </>
) : ( ) : (
<> <>
<Button variant="outline" size="icon" onClick={() => setViewDate(d => subMonths(d, 1))} className="h-8 w-8 rounded-lg"> <Button
variant="outline"
size="icon"
onClick={() => setViewDate((d) => subMonths(d, 1))}
className="h-8 w-8 rounded-lg"
>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<span className="text-sm font-medium w-36 text-center"> <span className="w-36 text-center text-sm font-medium">
{format(viewDate, "MMMM yyyy")} {format(viewDate, "MMMM yyyy")}
</span> </span>
<Button variant="outline" size="icon" onClick={() => setViewDate(d => addMonths(d, 1))} className="h-8 w-8 rounded-lg"> <Button
variant="outline"
size="icon"
onClick={() => setViewDate((d) => addMonths(d, 1))}
className="h-8 w-8 rounded-lg"
>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</> </>
)} )}
</div> </div>
<div className="flex items-center space-x-2 ml-auto"> <div className="ml-auto flex items-center space-x-2">
{/* View Switcher */} {/* View Switcher */}
<div className="bg-muted p-1 rounded-lg flex text-sm"> <div className="bg-muted flex rounded-lg p-1 text-sm">
<button <button
type="button" type="button"
onClick={() => setView("month")} onClick={() => setView("month")}
className={cn("px-3 py-1.5 rounded-md transition-all text-center font-medium", view === "month" ? "bg-background shadow text-foreground" : "text-muted-foreground hover:text-foreground")} className={cn(
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
view === "month"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
)}
> >
Month Month
</button> </button>
<button <button
type="button" type="button"
onClick={() => setView("week")} onClick={() => setView("week")}
className={cn("px-3 py-1.5 rounded-md transition-all text-center font-medium", view === "week" ? "bg-background shadow text-foreground" : "text-muted-foreground hover:text-foreground")} className={cn(
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
view === "week"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
)}
> >
Week Week
</button> </button>
@@ -150,7 +202,7 @@ export function InvoiceCalendarView({
</div> </div>
</div> </div>
<div className="flex-1 w-full overflow-hidden"> <div className="w-full flex-1 overflow-hidden">
{view === "month" ? ( {view === "month" ? (
<Calendar <Calendar
mode="single" mode="single"
@@ -158,7 +210,7 @@ export function InvoiceCalendarView({
onSelect={handleSelectDate} onSelect={handleSelectDate}
month={viewDate} month={viewDate}
onMonthChange={setViewDate} onMonthChange={setViewDate}
className="rounded-md border-0 w-full p-0" className="w-full rounded-md border-0 p-0"
classNames={{ classNames={{
root: "w-full p-0", root: "w-full p-0",
months: "flex flex-col w-full", months: "flex flex-col w-full",
@@ -173,7 +225,8 @@ export function InvoiceCalendarView({
// Use calc(100%/7) via tailwind arbitrary or just flex bases. // Use calc(100%/7) via tailwind arbitrary or just flex bases.
// Better: w-[14.28%] flex-none (approx 1/7) // Better: w-[14.28%] flex-none (approx 1/7)
weekdays: "flex w-full border-b", weekdays: "flex w-full border-b",
weekday: "w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4", weekday:
"w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
week: "flex w-full mt-2", week: "flex w-full mt-2",
cell: "w-[14.285%] flex-none h-20 sm:h-28 md:h-32 border-b p-0 relative focus-within:relative focus-within:z-20 text-center text-sm", cell: "w-[14.285%] flex-none h-20 sm:h-28 md:h-32 border-b p-0 relative focus-within:relative focus-within:z-20 text-center text-sm",
@@ -183,7 +236,7 @@ export function InvoiceCalendarView({
caption: "hidden", caption: "hidden",
day: cn( day: cn(
"w-full h-full p-2 font-normal aria-selected:opacity-100 flex flex-col items-start justify-start gap-1 hover:bg-accent/50 hover:text-accent-foreground align-top transition-colors rounded-xl" "w-full h-full p-2 font-normal aria-selected:opacity-100 flex flex-col items-start justify-start gap-1 hover:bg-accent/50 hover:text-accent-foreground align-top transition-colors rounded-xl",
), ),
day_selected: "bg-primary/5 text-primary", day_selected: "bg-primary/5 text-primary",
day_today: "bg-accent/20", day_today: "bg-accent/20",
@@ -204,36 +257,61 @@ export function InvoiceCalendarView({
{...buttonProps} {...buttonProps}
type="button" type="button"
className={cn( className={cn(
"relative flex h-full w-full flex-col items-start justify-between p-2 transition-all rounded-xl border border-transparent hover:border-border/50 hover:bg-secondary/30 text-left overflow-hidden", "hover:border-border/50 hover:bg-secondary/30 relative flex h-full w-full flex-col items-start justify-between overflow-hidden rounded-xl border border-transparent p-2 text-left transition-all",
// Selected State: Filled Box, No Outline // Selected State: Filled Box, No Outline
modifiers.selected && "bg-primary text-primary-foreground hover:bg-primary/90 shadow-md transform scale-[0.98]", modifiers.selected &&
modifiers.today && !modifiers.selected && "bg-accent/40 rounded-xl", "bg-primary text-primary-foreground hover:bg-primary/90 scale-[0.98] transform shadow-md",
className modifiers.today &&
!modifiers.selected &&
"bg-accent/40 rounded-xl",
className,
)} )}
> >
<span className="text-sm font-medium z-10">{DayDate.getDate()}</span> <span className="z-10 text-sm font-medium">
{DayDate.getDate()}
</span>
{dayItems.length > 0 && ( {dayItems.length > 0 && (
<div className="flex flex-col gap-1 w-full mt-1 overflow-hidden h-full justify-end pb-1"> <div className="mt-1 flex h-full w-full flex-col justify-end gap-1 overflow-hidden pb-1">
<div className="flex flex-col gap-1 w-full mt-1"> <div className="mt-1 flex w-full flex-col gap-1">
{dayItems.slice(0, 4).map((item, idx) => ( {dayItems.slice(0, 4).map((item, idx) => (
<div key={idx} className={cn("h-1 w-full rounded-full", modifiers.selected ? "bg-primary-foreground/50" : "bg-primary/50")} /> <div
key={idx}
className={cn(
"h-1 w-full rounded-full",
modifiers.selected
? "bg-primary-foreground/50"
: "bg-primary/50",
)}
/>
))} ))}
{dayItems.length > 4 && <div className={cn("h-1 w-1/3 rounded-full", modifiers.selected ? "bg-primary-foreground/30" : "bg-muted-foreground/30")} />} {dayItems.length > 4 && (
<div
className={cn(
"h-1 w-1/3 rounded-full",
modifiers.selected
? "bg-primary-foreground/30"
: "bg-muted-foreground/30",
)}
/>
)}
</div> </div>
</div> </div>
)} )}
</button> </button>
); );
} },
}} }}
/> />
) : ( ) : (
<div className="flex gap-3 overflow-x-auto p-4 pb-6 w-full"> <div className="flex w-full gap-3 overflow-x-auto p-4 pb-6">
{weekDays.map((day) => { {weekDays.map((day) => {
const isSelected = date && isSameDay(day, date); const isSelected = date && isSameDay(day, date);
const isToday = isSameDay(day, new Date()); const isToday = isSameDay(day, new Date());
const dayItems = getItemsForDate(day); const dayItems = getItemsForDate(day);
const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0); const totalHours = dayItems.reduce(
(acc, curr) => acc + curr.item.hours,
0,
);
return ( return (
<button <button
@@ -241,34 +319,49 @@ export function InvoiceCalendarView({
type="button" type="button"
onClick={() => handleSelectDate(day)} onClick={() => handleSelectDate(day)}
className={cn( className={cn(
"flex flex-col min-h-[260px] flex-shrink-0 w-[120px] sm:flex-1 sm:w-auto border rounded-3xl p-3 text-left transition-all hover:bg-accent/30", "hover:bg-accent/30 flex min-h-[260px] w-[120px] flex-shrink-0 flex-col rounded-3xl border p-3 text-left transition-all sm:w-auto sm:flex-1",
isSelected ? "ring-2 ring-primary ring-offset-2 bg-primary/5" : "bg-background/40", isSelected
isToday && !isSelected ? "bg-accent/40" : "" ? "ring-primary bg-primary/5 ring-2 ring-offset-2"
: "bg-background/40",
isToday && !isSelected ? "bg-accent/40" : "",
)} )}
> >
<div className="flex flex-col items-center mb-4 pb-4 border-b w-full"> <div className="mb-4 flex w-full flex-col items-center border-b pb-4">
<span className="text-xs font-bold text-muted-foreground uppercase">{format(day, "EEE")}</span> <span className="text-muted-foreground text-xs font-bold uppercase">
<span className="text-2xl font-light">{format(day, "d")}</span> {format(day, "EEE")}
</span>
<span className="text-2xl font-light">
{format(day, "d")}
</span>
</div> </div>
<div className="flex-1 space-y-2 w-full overflow-hidden"> <div className="w-full flex-1 space-y-2 overflow-hidden">
{dayItems.length > 0 ? ( {dayItems.length > 0 ? (
dayItems.map(({ item }, i) => ( dayItems.map(({ item }, i) => (
<div key={i} className="bg-background rounded-xl p-2 text-xs shadow-sm border"> <div
<div className="font-medium line-clamp-2 text-wrap break-words">{item.description || "No description"}</div> key={i}
<div className="text-muted-foreground whitespace-nowrap">{item.hours}h</div> className="bg-background rounded-xl border p-2 text-xs shadow-sm"
>
<div className="line-clamp-2 font-medium text-wrap break-words">
{item.description || "No description"}
</div>
<div className="text-muted-foreground whitespace-nowrap">
{item.hours}h
</div>
</div> </div>
)) ))
) : ( ) : (
<div className="h-full flex items-center justify-center text-muted-foreground/20"> <div className="text-muted-foreground/20 flex h-full items-center justify-center">
<Plus className="w-8 h-8" /> <Plus className="h-8 w-8" />
</div> </div>
)} )}
</div> </div>
{dayItems.length > 0 && ( {dayItems.length > 0 && (
<div className="pt-2 mt-auto text-center w-full"> <div className="mt-auto w-full pt-2 text-center">
<span className="text-sm font-semibold">{totalHours}h Total</span> <span className="text-sm font-semibold">
{totalHours}h Total
</span>
</div> </div>
)} )}
</button> </button>
@@ -279,47 +372,60 @@ export function InvoiceCalendarView({
</div> </div>
{/* Sheet for Day Details */} {/* Sheet for Day Details */}
<Sheet <Sheet open={sheetOpen} onOpenChange={handleCloseSheet}>
open={sheetOpen} <SheetContent
onOpenChange={handleCloseSheet} side="right"
className="flex w-full max-w-full flex-col gap-0 p-0 sm:w-[400px] sm:max-w-[540px]"
> >
<SheetContent side="right" className="w-full max-w-full sm:w-[400px] sm:max-w-[540px] flex flex-col gap-0 p-0"> <SheetHeader className="border-b p-6">
<SheetHeader className="p-6 border-b"> <SheetTitle className="flex flex-wrap items-center gap-3 text-2xl">
<SheetTitle className="flex items-center gap-3 text-2xl flex-wrap"> <div className="bg-primary/10 flex-shrink-0 rounded-full p-2.5">
<div className="bg-primary/10 p-2.5 rounded-full flex-shrink-0"> <CalendarIcon className="text-primary h-6 w-6" />
<CalendarIcon className="w-6 h-6 text-primary" />
</div> </div>
<span className="break-words text-left">{date ? format(date, "EEEE, MMMM do") : "Details"}</span> <span className="text-left break-words">
{date ? format(date, "EEEE, MMMM do") : "Details"}
</span>
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="space-y-6"> <div className="space-y-6">
{date && selectedDateItems.length === 0 ? ( {date && selectedDateItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4 bg-secondary/20 rounded-3xl border border-dashed border-border/60"> <div className="bg-secondary/20 border-border/60 flex flex-col items-center justify-center space-y-4 rounded-3xl border border-dashed py-16 text-center">
<div className="bg-background p-4 rounded-full shadow-sm"> <div className="bg-background rounded-full p-4 shadow-sm">
<Clock className="w-8 h-8 text-muted-foreground/50" /> <Clock className="text-muted-foreground/50 h-8 w-8" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="font-semibold text-lg text-foreground">No hours logged</p> <p className="text-foreground text-lg font-semibold">
<p className="text-sm text-muted-foreground/80 max-w-[200px]">There are no time entries recorded for this day yet.</p> No hours logged
</p>
<p className="text-muted-foreground/80 max-w-[200px] text-sm">
There are no time entries recorded for this day yet.
</p>
</div> </div>
<Button onClick={handleAddNewItem} className="mt-2" size="lg"> <Button onClick={handleAddNewItem} className="mt-2" size="lg">
<Plus className="w-4 h-4 mr-2" /> <Plus className="mr-2 h-4 w-4" />
Log Time Log Time
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{selectedDateItems.map(({ item, index }) => ( {selectedDateItems.map(({ item, index }) => (
<div key={item.id} className="border-border bg-card overflow-hidden rounded-lg border group hover:border-primary/50 transition-colors"> <div
key={item.id}
className="border-border bg-card group hover:border-primary/50 overflow-hidden rounded-lg border transition-colors"
>
<div className="space-y-3 p-4"> <div className="space-y-3 p-4">
{/* Description */} {/* Description */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-muted-foreground text-xs">Description</Label> <Label className="text-muted-foreground text-xs">
Description
</Label>
<Input <Input
value={item.description} value={item.description}
onChange={(e) => onUpdateItem(index, "description", e.target.value)} onChange={(e) =>
onUpdateItem(index, "description", e.target.value)
}
placeholder="Describe the work performed..." placeholder="Describe the work performed..."
className="pl-3 text-sm" className="pl-3 text-sm"
/> />
@@ -328,20 +434,24 @@ export function InvoiceCalendarView({
{/* Hours and Rate in a row */} {/* Hours and Rate in a row */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-muted-foreground text-xs">Hours</Label> <Label className="text-muted-foreground text-xs">
Hours
</Label>
<NumberInput <NumberInput
value={item.hours} value={item.hours}
onChange={v => onUpdateItem(index, "hours", v)} onChange={(v) => onUpdateItem(index, "hours", v)}
step={0.25} step={0.25}
min={0} min={0}
width="full" width="full"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-muted-foreground text-xs">Rate</Label> <Label className="text-muted-foreground text-xs">
Rate
</Label>
<NumberInput <NumberInput
value={item.rate} value={item.rate}
onChange={v => onUpdateItem(index, "rate", v)} onChange={(v) => onUpdateItem(index, "rate", v)}
prefix="$" prefix="$"
min={0} min={0}
step={1} step={1}
@@ -370,7 +480,9 @@ export function InvoiceCalendarView({
</span> </span>
</div> </div>
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs">Total</span> <span className="text-muted-foreground text-xs">
Total
</span>
<span className="text-primary text-lg font-bold"> <span className="text-primary text-lg font-bold">
${(item.hours * item.rate).toFixed(2)} ${(item.hours * item.rate).toFixed(2)}
</span> </span>
@@ -378,9 +490,13 @@ export function InvoiceCalendarView({
</div> </div>
</div> </div>
))} ))}
<Button variant="outline" onClick={handleAddNewItem} className="w-full border-dashed py-8 rounded-xl hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary transition-all gap-2 group"> <Button
<div className="bg-muted group-hover:bg-primary/10 p-1 rounded-md transition-colors"> variant="outline"
<Plus className="w-4 h-4" /> onClick={handleAddNewItem}
className="hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary group w-full gap-2 rounded-xl border-dashed py-8 transition-all"
>
<div className="bg-muted group-hover:bg-primary/10 rounded-md p-1 transition-colors">
<Plus className="h-4 w-4" />
</div> </div>
<span>Add Another Entry</span> <span>Add Another Entry</span>
</Button> </Button>
@@ -388,8 +504,14 @@ export function InvoiceCalendarView({
)} )}
</div> </div>
</div> </div>
<SheetFooter className="p-6 border-t bg-muted/10 mt-auto"> <SheetFooter className="bg-muted/10 mt-auto border-t p-6">
<Button className="w-full sm:w-full rounded-xl h-12 text-base shadow-md" size="lg" onClick={() => handleCloseSheet(false)}>Done</Button> <Button
className="h-12 w-full rounded-xl text-base shadow-md sm:w-full"
size="lg"
onClick={() => handleCloseSheet(false)}
>
Done
</Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
+171 -35
View File
@@ -76,12 +76,25 @@ function InvoiceFormSkeleton() {
); );
} }
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { function getDefaultHourlyRate(value: unknown) {
const router = useRouter(); if (typeof value !== "object" || value === null) return null;
const utils = api.useUtils();
// State const rate = (value as { defaultHourlyRate?: unknown }).defaultHourlyRate;
const [formData, setFormData] = useState<InvoiceFormData>({ return typeof rate === "number" ? rate : null;
}
function plainTextToHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\n/g, "<br>");
}
function createDefaultInvoiceFormData(): InvoiceFormData {
return {
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`, invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
invoicePrefix: "#", invoicePrefix: "#",
businessId: "", businessId: "",
@@ -90,6 +103,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
dueDate: new Date(), dueDate: new Date(),
status: "draft", status: "draft",
notes: "", notes: "",
emailMessage: "",
taxRate: 0, taxRate: 0,
currency: "USD", currency: "USD",
defaultHourlyRate: null, defaultHourlyRate: null,
@@ -103,12 +117,23 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
amount: 0, amount: 0,
}, },
], ],
}); };
}
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter();
const utils = api.useUtils();
// State
const [formData, setFormData] = useState<InvoiceFormData>(
createDefaultInvoiceFormData,
);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState("details"); const [activeTab, setActiveTab] = useState("details");
const [previewTab, setPreviewTab] = useState("pdf");
// Queries (Same as before) // Queries (Same as before)
const { data: clients, isLoading: loadingClients } = const { data: clients, isLoading: loadingClients } =
@@ -134,24 +159,22 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
// Init Effects (Same as before) // Init Effects (Same as before)
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset initialization state when the routed invoice changes.
setInitialized(false); setInitialized(false);
}, [invoiceId]); }, [invoiceId]);
useEffect(() => { useEffect(() => {
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) { if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
// ... (Mapping logic same as before) // ... (Mapping logic same as before)
const mappedItems: InvoiceItem[] = const mappedItems: InvoiceItem[] =
existingInvoice.items existingInvoice.items?.map((item) => ({
?.map((item) => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
date: new Date(item.date), date: new Date(item.date),
description: item.description, description: item.description,
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.amount, amount: item.amount,
})) })) || [];
.sort( // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
) || [];
setFormData({ setFormData({
invoiceNumber: existingInvoice.invoiceNumber, invoiceNumber: existingInvoice.invoiceNumber,
invoicePrefix: existingInvoice.invoicePrefix ?? "#", invoicePrefix: existingInvoice.invoicePrefix ?? "#",
@@ -161,6 +184,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
dueDate: new Date(existingInvoice.dueDate), dueDate: new Date(existingInvoice.dueDate),
status: existingInvoice.status as "draft" | "sent" | "paid", status: existingInvoice.status as "draft" | "sent" | "paid",
notes: existingInvoice.notes ?? "", notes: existingInvoice.notes ?? "",
emailMessage: existingInvoice.emailMessage ?? "",
taxRate: existingInvoice.taxRate, taxRate: existingInvoice.taxRate,
currency: existingInvoice.currency ?? "USD", currency: existingInvoice.currency ?? "USD",
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null, defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
@@ -201,6 +225,45 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const total = subtotal + taxAmount; const total = subtotal + taxAmount;
return { subtotal, taxAmount, total }; return { subtotal, taxAmount, total };
}, [formData.items, formData.taxRate]); }, [formData.items, formData.taxRate]);
const emailPreviewMessage = React.useMemo(
() => plainTextToHtml(formData.emailMessage.trim()),
[formData.emailMessage],
);
const pdfPreviewInput = React.useMemo(
() => ({
invoiceNumber: formData.invoiceNumber,
invoicePrefix: formData.invoicePrefix,
businessId: formData.businessId || "",
clientId: formData.clientId,
issueDate: formData.issueDate,
dueDate: formData.dueDate,
status: formData.status,
notes: formData.notes,
emailMessage: formData.emailMessage,
taxRate: formData.taxRate,
currency: formData.currency,
items: formData.items.map((item) => ({
date: item.date,
description: item.description || "Service",
hours: item.hours,
rate: item.rate,
})),
}),
[formData],
);
const { data: pdfPreview, isFetching: pdfPreviewLoading } =
api.invoices.previewPdf.useQuery(pdfPreviewInput, {
enabled:
activeTab === "preview" &&
previewTab === "pdf" &&
Boolean(formData.clientId) &&
formData.items.length > 0 &&
formData.items.every((item) => item.description.trim() !== ""),
refetchOnWindowFocus: false,
staleTime: 0,
});
const selectedClient = React.useMemo( const selectedClient = React.useMemo(
() => clients?.find((client) => client.id === formData.clientId), () => clients?.find((client) => client.id === formData.clientId),
[clients, formData.clientId], [clients, formData.clientId],
@@ -339,13 +402,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
dueDate: formData.dueDate, dueDate: formData.dueDate,
status: formData.status, status: formData.status,
notes: formData.notes, notes: formData.notes,
emailMessage: formData.emailMessage,
taxRate: formData.taxRate, taxRate: formData.taxRate,
currency: formData.currency, currency: formData.currency,
items: formData.items items: formData.items.map((i) => ({
.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
)
.map((i) => ({
date: i.date, date: i.date,
description: i.description, description: i.description,
hours: i.hours, hours: i.hours,
@@ -454,18 +514,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const currentBusiness = businesses?.find( const currentBusiness = businesses?.find(
(b) => b.id === formData.businessId, (b) => b.id === formData.businessId,
); );
const clientRate = const clientRate = getDefaultHourlyRate(selectedClient);
selectedClient && "defaultHourlyRate" in selectedClient
? selectedClient.defaultHourlyRate
: null;
const businessRate = const businessRate =
currentBusiness && getDefaultHourlyRate(currentBusiness);
"defaultHourlyRate" in currentBusiness
? currentBusiness.defaultHourlyRate
: null;
updateField( updateField(
"defaultHourlyRate", "defaultHourlyRate",
(clientRate ?? businessRate ?? 0) as number, clientRate ?? businessRate ?? 0,
); );
// Auto-fill currency from client // Auto-fill currency from client
if ( if (
@@ -473,10 +527,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
"currency" in selectedClient && "currency" in selectedClient &&
selectedClient.currency selectedClient.currency
) { ) {
updateField( updateField("currency", selectedClient.currency);
"currency",
selectedClient.currency as string,
);
} }
}} }}
> >
@@ -630,12 +681,29 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardContent> </CardContent>
</Card> </Card>
{/* Notes card — spans both columns */} <Card className="h-fit">
<Card className="h-fit lg:col-span-2">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-base"> <CardTitle className="flex items-center justify-between gap-2 text-base">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<FileText className="h-4 w-4" /> Notes <Mail className="h-4 w-4" /> Email Message
</span>
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={formData.emailMessage}
onChange={(e) => updateField("emailMessage", e.target.value)}
placeholder="Add a note that appears only in the email body..."
className="min-h-[140px]"
/>
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-base">
<span className="flex items-center gap-2">
<FileText className="h-4 w-4" /> Invoice Notes
</span> </span>
{noteTemplates && noteTemplates.length > 0 && ( {noteTemplates && noteTemplates.length > 0 && (
<DropdownMenu> <DropdownMenu>
@@ -666,8 +734,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Textarea <Textarea
value={formData.notes} value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)} onChange={(e) => updateField("notes", e.target.value)}
placeholder="Add notes, payment terms, or other information for the client…" placeholder="Add notes, payment terms, or other information for the invoice/PDF..."
className="min-h-[100px]" className="min-h-[140px]"
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -751,6 +819,67 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
value="preview" value="preview"
className="mt-6 focus-visible:outline-none" className="mt-6 focus-visible:outline-none"
> >
<Tabs
value={previewTab}
onValueChange={setPreviewTab}
className="w-full"
>
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1">
<TabsTrigger
value="pdf"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
PDF
</TabsTrigger>
<TabsTrigger
value="email"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Email
</TabsTrigger>
</TabsList>
<TabsContent value="pdf" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="flex gap-2">
<FileText className="h-5 w-5" /> PDF Preview
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="bg-muted/20 h-[760px] overflow-hidden border-t">
{!formData.clientId ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Select a client to generate the PDF preview.
</div>
) : formData.items.some(
(item) => item.description.trim() === "",
) ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Add descriptions for all line items to generate the
PDF preview.
</div>
) : pdfPreviewLoading && !pdfPreview ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Generating server PDF preview...
</div>
) : pdfPreview ? (
<iframe
title="Server-generated PDF preview"
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
className="h-full w-full border-0"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
PDF preview will appear here.
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="email" className="mt-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex gap-2"> <CardTitle className="flex gap-2">
@@ -765,6 +894,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
fromEmail={selectedBusiness?.email ?? ""} fromEmail={selectedBusiness?.email ?? ""}
toEmail={selectedClient?.email ?? ""} toEmail={selectedClient?.email ?? ""}
content="" content=""
customMessage={emailPreviewMessage}
invoice={{ invoice={{
invoiceNumber: formData.invoiceNumber, invoiceNumber: formData.invoiceNumber,
issueDate: formData.issueDate, issueDate: formData.issueDate,
@@ -772,6 +902,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
taxRate: formData.taxRate, taxRate: formData.taxRate,
status: formData.status, status: formData.status,
totalAmount: totals.total, totalAmount: totals.total,
currency: formData.currency,
client: selectedClient client: selectedClient
? { ? {
name: selectedClient.name, name: selectedClient.name,
@@ -786,8 +917,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
: undefined, : undefined,
items: formData.items.map((item) => ({ items: formData.items.map((item) => ({
id: item.id, id: item.id,
date: item.date,
description: item.description,
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.hours * item.rate,
})), })),
}} }}
/> />
@@ -795,6 +929,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</Card> </Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</TabsContent>
</Tabs>
</div> </div>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
@@ -13,20 +13,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { import { STATUS_OPTIONS } from "./types";
STATUS_OPTIONS, import type { InvoiceFormData, ClientType, BusinessType } from "./types";
} from "./types";
import type {
InvoiceFormData,
ClientType,
BusinessType,
} from "./types";
interface InvoiceMetaSidebarProps { interface InvoiceMetaSidebarProps {
formData: InvoiceFormData; formData: InvoiceFormData;
updateField: <K extends keyof InvoiceFormData>( updateField: <K extends keyof InvoiceFormData>(
field: K, field: K,
value: InvoiceFormData[K] value: InvoiceFormData[K],
) => void; ) => void;
clients: ClientType[] | undefined; clients: ClientType[] | undefined;
businesses: BusinessType[] | undefined; businesses: BusinessType[] | undefined;
@@ -41,15 +35,17 @@ export function InvoiceMetaSidebar({
className, className,
}: InvoiceMetaSidebarProps) { }: InvoiceMetaSidebarProps) {
return ( return (
<div className={cn("flex flex-col gap-6 p-4 h-full", className)}> <div className={cn("flex h-full flex-col gap-6 p-4", className)}>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider"> <h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Invoice Details Invoice Details
</h3> </h3>
{/* Status */} {/* Status */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="status" className="text-xs">Status</Label> <Label htmlFor="status" className="text-xs">
Status
</Label>
<Select <Select
value={formData.status} value={formData.status}
onValueChange={(value: "draft" | "sent" | "paid") => onValueChange={(value: "draft" | "sent" | "paid") =>
@@ -71,7 +67,9 @@ export function InvoiceMetaSidebar({
{/* Invoice Number */} {/* Invoice Number */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="invoiceNumber" className="text-xs">Invoice Number</Label> <Label htmlFor="invoiceNumber" className="text-xs">
Invoice Number
</Label>
<Input <Input
id="invoiceNumber" id="invoiceNumber"
value={formData.invoiceNumber} value={formData.invoiceNumber}
@@ -83,18 +81,23 @@ export function InvoiceMetaSidebar({
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider"> <h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Involved Parties Involved Parties
</h3> </h3>
{/* From (Business) */} {/* From (Business) */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="business" className="text-xs">From (Business)</Label> <Label htmlFor="business" className="text-xs">
From (Business)
</Label>
<Select <Select
value={formData.businessId} value={formData.businessId}
onValueChange={(value) => updateField("businessId", value)} onValueChange={(value) => updateField("businessId", value)}
> >
<SelectTrigger aria-label="From Business" className="bg-background/50 text-sm"> <SelectTrigger
aria-label="From Business"
className="bg-background/50 text-sm"
>
<span className="truncate"> <span className="truncate">
<SelectValue placeholder="Select business" /> <SelectValue placeholder="Select business" />
</span> </span>
@@ -102,7 +105,8 @@ export function InvoiceMetaSidebar({
<SelectContent> <SelectContent>
{businesses?.map((business) => ( {businesses?.map((business) => (
<SelectItem key={business.id} value={business.id}> <SelectItem key={business.id} value={business.id}>
{business.name}{business.nickname ? ` (${business.nickname})` : ""} {business.name}
{business.nickname ? ` (${business.nickname})` : ""}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -111,12 +115,17 @@ export function InvoiceMetaSidebar({
{/* Bill To (Client) */} {/* Bill To (Client) */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="client" className="text-xs">Bill To (Client)</Label> <Label htmlFor="client" className="text-xs">
Bill To (Client)
</Label>
<Select <Select
value={formData.clientId} value={formData.clientId}
onValueChange={(value) => updateField("clientId", value)} onValueChange={(value) => updateField("clientId", value)}
> >
<SelectTrigger aria-label="Bill To Client" className="bg-background/50 text-sm"> <SelectTrigger
aria-label="Bill To Client"
className="bg-background/50 text-sm"
>
<span className="truncate"> <span className="truncate">
<SelectValue placeholder="Select client" /> <SelectValue placeholder="Select client" />
</span> </span>
@@ -133,7 +142,7 @@ export function InvoiceMetaSidebar({
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider"> <h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Dates Dates
</h3> </h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -141,23 +150,27 @@ export function InvoiceMetaSidebar({
<Label className="text-xs">Issued</Label> <Label className="text-xs">Issued</Label>
<DatePicker <DatePicker
date={formData.issueDate} date={formData.issueDate}
onDateChange={(date) => updateField("issueDate", date ?? new Date())} onDateChange={(date) =>
className="w-full bg-background/50" updateField("issueDate", date ?? new Date())
}
className="bg-background/50 w-full"
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs">Due</Label> <Label className="text-xs">Due</Label>
<DatePicker <DatePicker
date={formData.dueDate} date={formData.dueDate}
onDateChange={(date) => updateField("dueDate", date ?? new Date())} onDateChange={(date) =>
className="w-full bg-background/50" updateField("dueDate", date ?? new Date())
}
className="bg-background/50 w-full"
/> />
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider"> <h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Config Config
</h3> </h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -182,19 +195,22 @@ export function InvoiceMetaSidebar({
prefix="$" prefix="$"
placeholder={!formData.clientId ? "Select client" : "Rate"} placeholder={!formData.clientId ? "Select client" : "Rate"}
disabled={!formData.clientId} disabled={!formData.clientId}
className={cn("bg-background/50", !formData.clientId && "opacity-50")} className={cn(
"bg-background/50",
!formData.clientId && "opacity-50",
)}
/> />
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-1.5 flex-1"> <div className="flex-1 space-y-1.5">
<Label className="text-xs">Notes</Label> <Label className="text-xs">Notes</Label>
<Textarea <Textarea
value={formData.notes} value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)} onChange={(e) => updateField("notes", e.target.value)}
placeholder="Notes for client..." placeholder="Notes for client..."
className="bg-background/50 resize-none h-24" className="bg-background/50 h-24 resize-none"
/> />
</div> </div>
</div> </div>
+1
View File
@@ -21,6 +21,7 @@ export interface InvoiceFormData {
dueDate: Date; dueDate: Date;
status: "draft" | "sent" | "paid"; status: "draft" | "sent" | "paid";
notes: string; notes: string;
emailMessage: string;
taxRate: number; taxRate: number;
currency: string; currency: string;
defaultHourlyRate: number | null; defaultHourlyRate: number | null;
-451
View File
@@ -1,451 +0,0 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button";
import { EmailComposer } from "./email-composer";
import { EmailPreview } from "./email-preview";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import {
Send,
Loader2,
Eye,
Edit3,
CheckCircle,
AlertTriangle,
Mail,
} from "lucide-react";
import { Alert, AlertDescription } from "~/components/ui/alert";
interface SendEmailDialogProps {
invoiceId: string;
trigger: React.ReactNode;
invoice?: {
id: string;
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
status: string;
taxRate: number;
client?: {
name: string;
email: string | null;
};
business?: {
name: string;
email: string | null;
};
items?: Array<{
id: string;
hours: number;
rate: number;
}>;
};
onEmailSent?: () => void;
}
export function SendEmailDialog({
invoiceId,
trigger,
invoice,
onEmailSent,
}: SendEmailDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState("compose");
const [isSending, setIsSending] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
// Email content state
const [subject, setSubject] = useState(() =>
invoice
? `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`
: "Invoice from Your Business",
);
const [ccEmail, setCcEmail] = useState("");
const [bccEmail, setBccEmail] = useState("");
const [customMessage, setCustomMessage] = useState("");
const [emailContent, setEmailContent] = useState(() => {
const getTimeOfDayGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
};
if (!invoice) return "";
const businessName = invoice.business?.name ?? "Your Business";
const issueDate = formatDate(invoice.issueDate);
// Calculate total from items
const subtotal =
invoice.items?.reduce((sum, item) => sum + item.hours * item.rate, 0) ??
0;
const taxAmount = subtotal * (invoice.taxRate / 100);
const total = subtotal + taxAmount;
return `<p>${getTimeOfDayGreeting()},</p>
<p>I hope this email finds you well. Please find attached invoice <strong>${invoice.invoiceNumber}</strong> dated ${issueDate}.</p>
<p>The invoice details are as follows:</p>
<ul>
<li><strong>Invoice Number:</strong> ${invoice.invoiceNumber}</li>
<li><strong>Issue Date:</strong> ${issueDate}</li>
<li><strong>Amount Due:</strong> ${new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)}</li>
</ul>
<p>Please let me know if you have any questions or need any clarification regarding this invoice. I appreciate your prompt attention to this matter.</p>
<p>Thank you for your business!</p>
<p>Best regards,<br><strong>${businessName}</strong></p>`;
});
// Get utils for cache invalidation
const utils = api.useUtils();
// Email sending mutation
const sendEmailMutation = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
toast.success("Email sent successfully!", {
description: data.message,
duration: 5000,
});
// Reset state and close dialog
setIsOpen(false);
setActiveTab("compose");
setIsSending(false);
setIsConfirming(false);
// Refresh invoice data
void utils.invoices.getById.invalidate({ id: invoiceId });
// Callback for parent component
onEmailSent?.();
},
onError: (error) => {
console.error("Email send error:", error);
let errorMessage = "Failed to send invoice email";
let errorDescription = error.message;
if (error.message.includes("Invalid recipient")) {
errorMessage = "Invalid Email Address";
errorDescription =
"Please check the client's email address and try again.";
} else if (error.message.includes("domain not verified")) {
errorMessage = "Email Configuration Issue";
errorDescription = "Please contact support to configure email sending.";
} else if (error.message.includes("rate limit")) {
errorMessage = "Too Many Emails";
errorDescription = "Please wait a moment before sending another email.";
} else if (error.message.includes("no email address")) {
errorMessage = "No Email Address";
errorDescription = "This client doesn't have an email address on file.";
}
toast.error(errorMessage, {
description: errorDescription,
duration: 6000,
});
setIsSending(false);
setIsConfirming(false);
},
});
const handleSendEmail = async () => {
if (!invoice?.client?.email || invoice.client.email.trim() === "") {
toast.error("No email address", {
description: "This client doesn't have an email address on file.",
});
return;
}
if (!subject.trim()) {
toast.error("Subject required", {
description: "Please enter an email subject before sending.",
});
return;
}
if (!emailContent.trim()) {
toast.error("Message required", {
description: "Please enter an email message before sending.",
});
return;
}
setIsSending(true);
try {
// Use the enhanced API with custom subject and content
await sendEmailMutation.mutateAsync({
invoiceId,
customSubject: subject,
customContent: emailContent,
customMessage: customMessage.trim() || undefined,
useHtml: true,
ccEmails: ccEmail.trim() || undefined,
bccEmails: bccEmail.trim() || undefined,
});
} catch (error) {
// Error handling is done in the mutation's onError
console.error("Send email error:", error);
}
};
const handleConfirmSend = () => {
setIsConfirming(true);
setActiveTab("confirm");
};
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com";
const toEmail = invoice?.client?.email ?? "";
const canSend =
!isSending &&
subject.trim() &&
emailContent.trim() &&
toEmail &&
toEmail.trim() !== "";
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="text-primary h-5 w-5" />
Send Invoice Email
</DialogTitle>
<DialogDescription>
Compose and preview your invoice email before sending to{" "}
{invoice?.client?.name ?? "client"}.
</DialogDescription>
</DialogHeader>
{/* Warning for missing email */}
{(!toEmail || toEmail.trim() === "") && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This client doesn&apos;t have an email address. Please add an
email address to the client before sending the invoice.
</AlertDescription>
</Alert>
)}
{/* Branded Template Info */}
<Alert>
<Mail className="h-4 w-4" />
<AlertDescription>
<strong>Professional Email Template:</strong> Your email will be
sent using a beautifully designed, beenvoice-branded template with
proper fonts and styling. Any custom content you add will be
incorporated into the professional template automatically.
</AlertDescription>
</Alert>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="min-h-0 flex-1"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="compose" className="flex items-center gap-2">
<Edit3 className="h-4 w-4" />
Compose
</TabsTrigger>
<TabsTrigger value="preview" className="flex items-center gap-2">
<Eye className="h-4 w-4" />
Preview
</TabsTrigger>
<TabsTrigger
value="confirm"
className="flex items-center gap-2"
disabled={!isConfirming}
>
<CheckCircle className="h-4 w-4" />
Confirm
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent
value="compose"
className="mt-4 h-full overflow-y-auto"
>
<EmailComposer
subject={subject}
onSubjectChange={setSubject}
content={emailContent}
onContentChange={setEmailContent}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
onCcEmailChange={setCcEmail}
bccEmail={bccEmail}
onBccEmailChange={setBccEmail}
/>
</TabsContent>
<TabsContent
value="preview"
className="mt-4 h-full overflow-y-auto"
>
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
bccEmail={bccEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
className="pr-2"
/>
</TabsContent>
<TabsContent
value="confirm"
className="mt-4 h-full overflow-y-auto"
>
<div className="space-y-6 pr-2">
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
You&apos;re about to send this email to{" "}
<strong>{toEmail}</strong>. The invoice PDF will be
automatically attached.
</AlertDescription>
</Alert>
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
/>
{invoice?.status === "draft" && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This invoice is currently in <strong>draft</strong>{" "}
status. Sending it will automatically change the status to{" "}
<strong>sent</strong>.
</AlertDescription>
</Alert>
)}
</div>
</TabsContent>
</div>
</Tabs>
<DialogFooter className="flex items-center justify-between">
<div className="flex items-center gap-2">
{activeTab === "compose" && (
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
disabled={isSending}
>
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
)}
{activeTab === "preview" && (
<>
<Button
variant="outline"
onClick={() => setActiveTab("compose")}
disabled={isSending}
>
<Edit3 className="mr-2 h-4 w-4" />
Edit
</Button>
<Button
onClick={handleConfirmSend}
disabled={!canSend}
variant="default"
>
<CheckCircle className="mr-2 h-4 w-4" />
Review & Send
</Button>
</>
)}
{activeTab === "confirm" && (
<>
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
disabled={isSending}
>
Back to Preview
</Button>
<Button
onClick={handleSendEmail}
disabled={!canSend || isSending}
className="bg-primary hover:bg-primary/90"
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Send Email
</>
)}
</Button>
</>
)}
</div>
<Button
variant="ghost"
onClick={() => setIsOpen(false)}
disabled={isSending}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+16 -8
View File
@@ -2,7 +2,10 @@
import * as React from "react"; import * as React from "react";
import { Sidebar } from "~/components/layout/sidebar"; import { Sidebar } from "~/components/layout/sidebar";
import { SidebarProvider, useSidebar } from "~/components/layout/sidebar-provider"; import {
SidebarProvider,
useSidebar,
} from "~/components/layout/sidebar-provider";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Menu } from "lucide-react"; import { Menu } from "lucide-react";
import { Logo } from "~/components/branding/logo"; import { Logo } from "~/components/branding/logo";
@@ -16,17 +19,22 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
const [isMobileOpen, setIsMobileOpen] = React.useState(false); const [isMobileOpen, setIsMobileOpen] = React.useState(false);
return ( return (
<div className="bg-dashboard relative min-h-screen flex"> <div className="bg-dashboard relative flex min-h-screen">
{/* Desktop Sidebar */} {/* Desktop Sidebar */}
<div className="hidden md:block"> <div className="hidden md:block">
<Sidebar /> <Sidebar />
</div> </div>
{/* Mobile Sidebar (Sheet) */} {/* Mobile Sidebar (Sheet) */}
<div className="fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b bg-background/80 px-4 backdrop-blur-md md:hidden"> <div className="dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden">
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}> <Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="outline" size="icon" className="h-10 w-10 bg-background shadow-sm" suppressHydrationWarning> <Button
variant="outline"
size="icon"
className="bg-background h-10 w-10 shadow-sm"
suppressHydrationWarning
>
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span> <span className="sr-only">Toggle menu</span>
</Button> </Button>
@@ -35,7 +43,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<div className="ml-4 flex items-center gap-2"> <div className="ml-4 flex items-center gap-2">
<Logo size="sm" /> <Logo size="sm" />
</div> </div>
<SheetContent side="left" className="p-0 w-72"> <SheetContent side="left" className="w-72 p-0">
<div className="sr-only"> <div className="sr-only">
<h2 id="mobile-nav-title">Navigation Menu</h2> <h2 id="mobile-nav-title">Navigation Menu</h2>
</div> </div>
@@ -48,7 +56,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<main <main
suppressHydrationWarning suppressHydrationWarning
className={cn( className={cn(
"flex-1 min-h-screen min-w-0 transition-all duration-300 ease-in-out", "min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out",
"md:ml-0", "md:ml-0",
sidebarStyle === "floating" sidebarStyle === "floating"
? isCollapsed ? isCollapsed
@@ -59,9 +67,9 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
: "md:ml-64", : "md:ml-64",
)} )}
> >
<div className="p-4 pt-16 md:pt-4"> <div className="dashboard-content-shell p-4 pt-16 md:pt-4">
{/* Mobile header spacer is handled by pt-16 on mobile */} {/* Mobile header spacer is handled by pt-16 on mobile */}
<div className="md:hidden mb-4"> <div className="mb-4 md:hidden">
{/* Mobile Breadcrumbs could go here or be part of the page */} {/* Mobile Breadcrumbs could go here or be part of the page */}
</div> </div>
{children} {children}
+5 -5
View File
@@ -4,21 +4,21 @@ import { cn } from "~/lib/utils";
export function MotionBackground() { export function MotionBackground() {
return ( return (
<div className="fixed inset-0 -z-50 overflow-hidden pointer-events-none bg-background"> <div className="bg-background pointer-events-none fixed inset-0 -z-50 overflow-hidden">
<div <div
className={cn( className={cn(
"absolute inset-[-50%] w-[200%] h-[200%]", "absolute inset-[-50%] h-[200%] w-[200%]",
"bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]", "bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]",
"from-[oklch(var(--primary)/0.15)] via-transparent to-transparent", "from-[oklch(var(--primary)/0.15)] via-transparent to-transparent",
"animate-subtle-spin opacity-100" "animate-subtle-spin opacity-100",
)} )}
/> />
<div <div
className={cn( className={cn(
"absolute inset-[-50%] w-[200%] h-[200%]", "absolute inset-[-50%] h-[200%] w-[200%]",
"bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]", "bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]",
"from-[oklch(var(--accent)/0.15)] via-transparent to-transparent", "from-[oklch(var(--accent)/0.15)] via-transparent to-transparent",
"animate-subtle-wave opacity-100" "animate-subtle-wave opacity-100",
)} )}
/> />
<div className="absolute inset-0 bg-[url('/noise.svg')] opacity-[0.02] mix-blend-overlay" /> <div className="absolute inset-0 bg-[url('/noise.svg')] opacity-[0.02] mix-blend-overlay" />
+7 -1
View File
@@ -8,7 +8,11 @@ import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export function Navbar() { interface NavbarProps {
allowRegistration?: boolean;
}
export function Navbar({ allowRegistration = true }: NavbarProps) {
const { data: session, isPending } = authClient.useSession(); const { data: session, isPending } = authClient.useSession();
// const session = { user: null } as any; const isPending = false; // const session = { user: null } as any; const isPending = false;
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false); const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
@@ -63,6 +67,7 @@ export function Navbar() {
Sign In Sign In
</Button> </Button>
</Link> </Link>
{allowRegistration && (
<Link href="/auth/register"> <Link href="/auth/register">
<Button <Button
size="sm" size="sm"
@@ -72,6 +77,7 @@ export function Navbar() {
Register Register
</Button> </Button>
</Link> </Link>
)}
</> </>
)} )}
</div> </div>
+10 -8
View File
@@ -42,22 +42,24 @@ export function PageHeader({
return ( return (
<div className={`animate-fade-in-down mb-6 ${className}`}> <div className={`animate-fade-in-down mb-6 ${className}`}>
{variant === "large-gradient" || variant === "gradient" ? ( {variant === "large-gradient" || variant === "gradient" ? (
<div className="platform-header-surface rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden relative"> <div className="platform-header-surface bg-card text-card-foreground relative overflow-hidden rounded-xl border shadow-sm">
<div className="platform-header-gradient absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent pointer-events-none" /> <div className="platform-header-gradient from-primary/5 pointer-events-none absolute inset-0 bg-gradient-to-br via-transparent to-transparent" />
<div className="platform-header-content p-6 relative"> <div className="platform-header-content relative p-6">
<DashboardBreadcrumbs className="mb-4" /> <DashboardBreadcrumbs className="mb-4" />
{/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */} {/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1"> <div className="space-y-1">
<h1 className={titleClassName ?? getTitleClasses()}>{title}</h1> <h1 className={titleClassName ?? getTitleClasses()}>{title}</h1>
{description && ( {description && (
<p className={`text-muted-foreground ${getDescriptionSpacing()} text-lg`}> <p
className={`text-muted-foreground ${getDescriptionSpacing()} text-lg`}
>
{description} {description}
</p> </p>
)} )}
</div> </div>
{children && ( {children && (
<div className="flex flex-shrink-0 gap-2 sm:gap-3 w-full sm:w-auto"> <div className="flex w-full flex-shrink-0 gap-2 sm:w-auto sm:gap-3">
{children} {children}
</div> </div>
)} )}
@@ -68,7 +70,7 @@ export function PageHeader({
<> <>
<DashboardBreadcrumbs className="mb-2 sm:mb-4" /> <DashboardBreadcrumbs className="mb-2 sm:mb-4" />
{/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */} {/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="animate-fade-in-up space-y-1"> <div className="animate-fade-in-up space-y-1">
<h1 className={titleClassName ?? getTitleClasses()}>{title}</h1> <h1 className={titleClassName ?? getTitleClasses()}>{title}</h1>
{description && ( {description && (
@@ -80,7 +82,7 @@ export function PageHeader({
)} )}
</div> </div>
{children && ( {children && (
<div className="animate-slide-in-right animate-delay-200 flex flex-shrink-0 gap-2 sm:gap-3 w-full sm:w-auto"> <div className="animate-slide-in-right animate-delay-200 flex w-full flex-shrink-0 gap-2 sm:w-auto sm:gap-3">
{children} {children}
</div> </div>
)} )}
+19 -28
View File
@@ -7,11 +7,7 @@ interface PageLayoutProps {
} }
export function PageLayout({ children, className }: PageLayoutProps) { export function PageLayout({ children, className }: PageLayoutProps) {
return ( return <div className={cn("min-h-screen", className)}>{children}</div>;
<div className={cn("min-h-screen", className)}>
{children}
</div>
);
} }
interface PageContentProps { interface PageContentProps {
@@ -23,18 +19,16 @@ interface PageContentProps {
export function PageContent({ export function PageContent({
children, children,
className, className,
spacing = "default" spacing = "default",
}: PageContentProps) { }: PageContentProps) {
const spacingClasses = { const spacingClasses = {
default: "space-y-8", default: "space-y-8",
compact: "space-y-4", compact: "space-y-4",
large: "space-y-12" large: "space-y-12",
}; };
return ( return (
<div className={cn(spacingClasses[spacing], className)}> <div className={cn(spacingClasses[spacing], className)}>{children}</div>
{children}
</div>
); );
} }
@@ -51,7 +45,7 @@ export function PageSection({
className, className,
title, title,
description, description,
actions actions,
}: PageSectionProps) { }: PageSectionProps) {
return ( return (
<section className={cn("space-y-4", className)}> <section className={cn("space-y-4", className)}>
@@ -59,15 +53,15 @@ export function PageSection({
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div> <div>
{title && ( {title && (
<h2 className="text-xl font-semibold text-foreground">{title}</h2> <h2 className="text-foreground text-xl font-semibold">{title}</h2>
)} )}
{description && ( {description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p> <p className="text-muted-foreground mt-1 text-sm">
{description}
</p>
)} )}
</div> </div>
{actions && ( {actions && <div className="flex flex-shrink-0 gap-3">{actions}</div>}
<div className="flex flex-shrink-0 gap-3">{actions}</div>
)}
</div> </div>
)} )}
{children} {children}
@@ -86,28 +80,25 @@ export function PageGrid({
children, children,
className, className,
columns = 3, columns = 3,
gap = "default" gap = "default",
}: PageGridProps) { }: PageGridProps) {
const columnClasses = { const columnClasses = {
1: "grid-cols-1", 1: "grid-cols-1",
2: "grid-cols-1 md:grid-cols-2", 2: "grid-cols-1 md:grid-cols-2",
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3", 3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4" 4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
}; };
const gapClasses = { const gapClasses = {
default: "gap-4", default: "gap-4",
compact: "gap-2", compact: "gap-2",
large: "gap-6" large: "gap-6",
}; };
return ( return (
<div className={cn( <div
"grid", className={cn("grid", columnClasses[columns], gapClasses[gap], className)}
columnClasses[columns], >
gapClasses[gap],
className
)}>
{children} {children}
</div> </div>
); );
@@ -127,18 +118,18 @@ export function EmptyState({
title, title,
description, description,
action, action,
className className,
}: EmptyStateProps) { }: EmptyStateProps) {
return ( return (
<div className={cn("py-12 text-center", className)}> <div className={cn("py-12 text-center", className)}>
{icon && ( {icon && (
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center bg-muted/50"> <div className="bg-muted/50 mx-auto mb-4 flex h-16 w-16 items-center justify-center">
{icon} {icon}
</div> </div>
)} )}
<h3 className="mb-2 text-lg font-semibold">{title}</h3> <h3 className="mb-2 text-lg font-semibold">{title}</h3>
{description && ( {description && (
<p className="text-muted-foreground mb-4 max-w-sm mx-auto"> <p className="text-muted-foreground mx-auto mb-4 max-w-sm">
{description} {description}
</p> </p>
)} )}
+4 -8
View File
@@ -14,15 +14,11 @@ const SidebarContext = React.createContext<SidebarContextType | undefined>(
); );
export function SidebarProvider({ children }: { children: React.ReactNode }) { export function SidebarProvider({ children }: { children: React.ReactNode }) {
const [isCollapsed, setIsCollapsed] = React.useState(false); const [isCollapsed, setIsCollapsed] = React.useState(() => {
if (typeof window === "undefined") return false;
// Persist state if needed, for now just local state
React.useEffect(() => {
const saved = localStorage.getItem("sidebar-collapsed"); const saved = localStorage.getItem("sidebar-collapsed");
if (saved) { return saved ? (JSON.parse(saved) as boolean) : false;
setIsCollapsed(JSON.parse(saved) as boolean); });
}
}, []);
const toggleCollapse = React.useCallback(() => { const toggleCollapse = React.useCallback(() => {
setIsCollapsed((prev) => { setIsCollapsed((prev) => {
+95 -46
View File
@@ -5,16 +5,17 @@ import { usePathname } from "next/navigation";
import { authClient } from "~/lib/auth-client"; import { authClient } from "~/lib/auth-client";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
LogOut,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import { navigationConfig } from "~/lib/navigation"; import { navigationConfig } from "~/lib/navigation";
import { useSidebar } from "./sidebar-provider"; import { useSidebar } from "./sidebar-provider";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Logo } from "~/components/branding/logo"; import { Logo } from "~/components/branding/logo";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip"; import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -46,10 +47,12 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
<div className="flex h-full flex-col justify-between"> <div className="flex h-full flex-col justify-between">
<div> <div>
{/* Header / Logo */} {/* Header / Logo */}
<div className={cn( <div
"flex items-center h-14 px-4 mb-2", className={cn(
collapsed ? "justify-center px-2" : "justify-between" "mb-2 flex h-14 items-center px-4",
)}> collapsed ? "justify-center px-2" : "justify-between",
)}
>
{!collapsed && ( {!collapsed && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Logo size="sm" /> <Logo size="sm" />
@@ -63,11 +66,16 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className={cn("flex flex-col px-2 gap-6 mt-4", collapsed && "items-center")}> <nav
className={cn(
"mt-4 flex flex-col gap-6 px-2",
collapsed && "items-center",
)}
>
{navigationConfig.map((section) => ( {navigationConfig.map((section) => (
<div key={section.title}> <div key={section.title}>
{!collapsed && ( {!collapsed && (
<div className="px-2 mb-2 text-xs font-semibold text-muted-foreground/60 tracking-wider uppercase"> <div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase">
{section.title} {section.title}
</div> </div>
)} )}
@@ -84,17 +92,21 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link <Link
href={link.href} href={link.href}
data-active={isActive ? "true" : undefined}
className={cn( className={cn(
"flex items-center justify-center h-10 w-10 rounded-md transition-colors", "flex h-10 w-10 items-center justify-center rounded-md transition-colors",
isActive isActive
? "bg-primary text-primary-foreground shadow-sm" ? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
)} )}
> >
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className="font-medium"> <TooltipContent
side="right"
className="font-medium"
>
{link.name} {link.name}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -106,12 +118,13 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
<Link <Link
key={link.href} key={link.href}
href={link.href} href={link.href}
data-active={isActive ? "true" : undefined}
onClick={mobile ? onClose : undefined} onClick={mobile ? onClose : undefined}
className={cn( className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors", "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive isActive
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
@@ -127,29 +140,45 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
</div> </div>
{/* Footer / User */} {/* Footer / User */}
<div className="p-2 mt-auto space-y-2"> <div className="mt-auto space-y-2 p-2">
{!mobile && ( {!mobile && (
<div className={cn("flex", collapsed ? "justify-center" : "justify-end px-2")}> <div
className={cn(
"flex",
collapsed ? "justify-center" : "justify-end px-2",
)}
>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-muted-foreground" className="text-muted-foreground h-8 w-8"
onClick={toggleCollapse} onClick={toggleCollapse}
> >
{collapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />} {collapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</Button> </Button>
</div> </div>
)} )}
<div className={cn( <div
"border-t border-border/50 pt-4", className={cn(
collapsed ? "flex flex-col items-center gap-2" : "px-2" "border-border/50 border-t pt-4",
)}> collapsed ? "flex flex-col items-center gap-2" : "px-2",
)}
>
{isPending ? ( {isPending ? (
<div className={cn("flex items-center gap-3", collapsed ? "justify-center" : "px-2")}> <div
className={cn(
"flex items-center gap-3",
collapsed ? "justify-center" : "px-2",
)}
>
<Skeleton className="h-9 w-9 rounded-full" /> <Skeleton className="h-9 w-9 rounded-full" />
{!collapsed && ( {!collapsed && (
<div className="space-y-1 flex-1"> <div className="flex-1 space-y-1">
<Skeleton className="h-3 w-20" /> <Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-24" /> <Skeleton className="h-2 w-24" />
</div> </div>
@@ -158,17 +187,37 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
) : session?.user ? ( ) : session?.user ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className={cn("w-full justify-start p-0 hover:bg-transparent", collapsed && "justify-center")}> <Button
variant="ghost"
className={cn(
"w-full justify-start p-0 hover:bg-transparent",
collapsed && "justify-center",
)}
>
{/* FIXED: Changed div to span to prevent hydration error */} {/* FIXED: Changed div to span to prevent hydration error */}
<span className={cn("flex items-center gap-3", collapsed ? "justify-center" : "w-full")}> <span
<Avatar className="h-9 w-9 border border-border"> className={cn(
<AvatarImage src={getGravatarUrl(session.user.email)} alt={session.user.name ?? "User"} /> "flex items-center gap-3",
<AvatarFallback>{session.user.name?.[0] ?? "U"}</AvatarFallback> collapsed ? "justify-center" : "w-full",
)}
>
<Avatar className="border-border h-9 w-9 border">
<AvatarImage
src={getGravatarUrl(session.user.email)}
alt={session.user.name ?? "User"}
/>
<AvatarFallback>
{session.user.name?.[0] ?? "U"}
</AvatarFallback>
</Avatar> </Avatar>
{!collapsed && ( {!collapsed && (
<span className="flex-1 min-w-0 text-left"> <span className="min-w-0 flex-1 text-left">
<span className="block text-sm font-medium truncate">{session.user.name}</span> <span className="block truncate text-sm font-medium">
<span className="block text-xs text-muted-foreground truncate">{session.user.email}</span> {session.user.name}
</span>
<span className="text-muted-foreground block truncate text-xs">
{session.user.email}
</span>
</span> </span>
)} )}
</span> </span>
@@ -177,13 +226,17 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
<DropdownMenuContent <DropdownMenuContent
side="right" side="right"
align="end" align="end"
className="w-56 bg-background/80 backdrop-blur-xl border-border/50" className="bg-background/80 border-border/50 w-56 backdrop-blur-xl"
sideOffset={10} sideOffset={10}
> >
<DropdownMenuLabel> <DropdownMenuLabel>
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{session.user.name}</p> <p className="text-sm leading-none font-medium">
<p className="text-xs leading-none text-muted-foreground">{session.user.email}</p> {session.user.name}
</p>
<p className="text-muted-foreground text-xs leading-none">
{session.user.email}
</p>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -192,7 +245,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
await authClient.signOut(); await authClient.signOut();
window.location.href = "/"; window.location.href = "/";
}} }}
className="text-red-600 focus:text-red-600 focus:bg-red-100/50 dark:focus:bg-red-900/20" className="text-red-600 focus:bg-red-100/50 focus:text-red-600 dark:focus:bg-red-900/20"
> >
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
Sign Out Sign Out
@@ -206,11 +259,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
); );
if (mobile) { if (mobile) {
return ( return <div className="bg-background h-full">{SidebarContent}</div>;
<div className="h-full bg-background">
{SidebarContent}
</div>
);
} }
return ( return (
@@ -218,8 +267,8 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
className={cn( className={cn(
"fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex", "fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex",
sidebarStyle === "floating" sidebarStyle === "floating"
? "top-4 bottom-4 left-4 border-border/50 rounded-3xl border bg-background/80 shadow-xl backdrop-blur-xl" ? "border-border/50 bg-background/80 top-4 bottom-4 left-4 rounded-3xl border shadow-xl backdrop-blur-xl"
: "top-0 bottom-0 left-0 rounded-none border-r border-border bg-background shadow-none", : "border-border bg-background top-0 bottom-0 left-0 rounded-none border-r shadow-none",
isCollapsed ? "w-16" : "w-64", isCollapsed ? "w-16" : "w-64",
)} )}
> >
+5 -2
View File
@@ -14,12 +14,15 @@ export function Breadcrumbs() {
})), })),
]; ];
return ( return (
<nav className="flex items-center text-sm text-muted-foreground" aria-label="Breadcrumb"> <nav
className="text-muted-foreground flex items-center text-sm"
aria-label="Breadcrumb"
>
{crumbs.map((crumb, i) => ( {crumbs.map((crumb, i) => (
<span key={crumb.href} className="flex items-center"> <span key={crumb.href} className="flex items-center">
{i > 0 && <ChevronRight className="mx-2 h-4 w-4 text-gray-300" />} {i > 0 && <ChevronRight className="mx-2 h-4 w-4 text-gray-300" />}
{i < crumbs.length - 1 ? ( {i < crumbs.length - 1 ? (
<Link href={crumb.href} className="hover:underline text-gray-500"> <Link href={crumb.href} className="text-gray-500 hover:underline">
{crumb.name} {crumb.name}
</Link> </Link>
) : ( ) : (
@@ -71,7 +71,8 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
aria-current={ aria-current={
pathname === link.href ? "page" : undefined pathname === link.href ? "page" : undefined
} }
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${pathname === link.href className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${
pathname === link.href
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "text-foreground hover:bg-muted" : "text-foreground hover:bg-muted"
}`} }`}
@@ -205,9 +205,9 @@ export function AnimationPreferencesProvider({
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const stored = readLocalStorage(); const stored = readLocalStorage();
const systemReduced = const systemReduced = window.matchMedia?.(
window.matchMedia && "(prefers-reduced-motion: reduce)",
window.matchMedia("(prefers-reduced-motion: reduce)").matches; ).matches;
const finalPrefers = const finalPrefers =
stored?.prefersReducedMotion ?? stored?.prefersReducedMotion ??
@@ -220,6 +220,7 @@ export function AnimationPreferencesProvider({
DEFAULT_SPEED, DEFAULT_SPEED,
); );
// eslint-disable-next-line react-hooks/set-state-in-effect -- Hydrate preferences from localStorage/system settings on mount.
setPrefersReducedMotion(finalPrefers); setPrefersReducedMotion(finalPrefers);
setAnimationSpeedMultiplier(finalSpeed); setAnimationSpeedMultiplier(finalSpeed);
applyPreferencesToDOM({ applyPreferencesToDOM({
@@ -279,7 +280,8 @@ export function AnimationPreferencesProvider({
// Optionally sync to server // Optionally sync to server
const shouldSync = opts?.sync ?? autoSync; const shouldSync = opts?.sync ?? autoSync;
if (shouldSync && serverPrefs) { // If serverPrefs exists, user is authenticated if (shouldSync && serverPrefs) {
// If serverPrefs exists, user is authenticated
pendingSyncRef.current = { pendingSyncRef.current = {
prefersReducedMotion: patch.prefersReducedMotion, prefersReducedMotion: patch.prefersReducedMotion,
animationSpeedMultiplier: patch.animationSpeedMultiplier, animationSpeedMultiplier: patch.animationSpeedMultiplier,
@@ -334,6 +336,7 @@ export function AnimationPreferencesProvider({
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier; serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) { if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reconcile loaded server preferences once after query hydration.
performUpdate( performUpdate(
{ {
prefersReducedMotion: serverPrefs.prefersReducedMotion, prefersReducedMotion: serverPrefs.prefersReducedMotion,
@@ -402,9 +405,15 @@ export function useAnimationPreferences(): AnimationPreferencesContextValue {
return { return {
prefersReducedMotion: false, prefersReducedMotion: false,
animationSpeedMultiplier: 1, animationSpeedMultiplier: 1,
updatePreferences: () => { /* no-op fallback */ }, updatePreferences: () => {
setPrefersReducedMotion: () => { /* no-op fallback */ }, /* no-op fallback */
setAnimationSpeedMultiplier: () => { /* no-op fallback */ }, },
setPrefersReducedMotion: () => {
/* no-op fallback */
},
setAnimationSpeedMultiplier: () => {
/* no-op fallback */
},
isUpdating: false, isUpdating: false,
lastSyncedAt: null, lastSyncedAt: null,
}; };
+116 -91
View File
@@ -6,10 +6,22 @@ import {
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import { import {
defaultFontPreference, fallbackAppearance,
isColorMode,
isColorTheme,
isFontPreference,
isHslChannels,
isInterfaceTheme,
isPdfTemplate,
isRadiusPreference,
isSidebarStyle,
type PdfTemplate,
} from "~/lib/appearance";
import {
defaultBodyFontPreference, defaultBodyFontPreference,
defaultHeadingFontPreference, defaultHeadingFontPreference,
defaultInterfaceTheme, defaultInterfaceTheme,
@@ -27,7 +39,6 @@ import { api } from "~/trpc/react";
type AppearancePreferences = { type AppearancePreferences = {
interfaceTheme: InterfaceTheme; interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference; bodyFontPreference: FontPreference;
headingFontPreference: FontPreference; headingFontPreference: FontPreference;
radiusPreference: RadiusPreference; radiusPreference: RadiusPreference;
@@ -39,7 +50,7 @@ type AppearancePreferences = {
brandTagline: string; brandTagline: string;
brandLogoText: string; brandLogoText: string;
brandIcon: string; brandIcon: string;
pdfTemplate: "classic" | "minimal"; pdfTemplate: PdfTemplate;
pdfAccentColor: string; pdfAccentColor: string;
pdfFooterText: string; pdfFooterText: string;
pdfShowLogo: boolean; pdfShowLogo: boolean;
@@ -50,7 +61,6 @@ type AppearancePatch = Partial<AppearancePreferences>;
type ServerAppearance = { type ServerAppearance = {
interfaceTheme: InterfaceTheme; interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference; bodyFontPreference: FontPreference;
headingFontPreference: FontPreference; headingFontPreference: FontPreference;
radiusPreference: RadiusPreference; radiusPreference: RadiusPreference;
@@ -62,7 +72,7 @@ type ServerAppearance = {
brandTagline: string; brandTagline: string;
brandLogoText: string; brandLogoText: string;
brandIcon: string; brandIcon: string;
pdfTemplate: "classic" | "minimal"; pdfTemplate: PdfTemplate;
pdfAccentColor: string; pdfAccentColor: string;
pdfFooterText: string; pdfFooterText: string;
pdfShowLogo: boolean; pdfShowLogo: boolean;
@@ -71,6 +81,7 @@ type ServerAppearance = {
type AppearanceContextValue = AppearancePreferences & { type AppearanceContextValue = AppearancePreferences & {
updateAppearance: (patch: AppearancePatch) => void; updateAppearance: (patch: AppearancePatch) => void;
updateAppearanceDebounced: (patch: AppearancePatch) => void;
isUpdating: boolean; isUpdating: boolean;
}; };
@@ -78,22 +89,21 @@ const STORAGE_KEY = "bv.appearance";
const defaultAppearance: AppearancePreferences = { const defaultAppearance: AppearancePreferences = {
interfaceTheme: defaultInterfaceTheme, interfaceTheme: defaultInterfaceTheme,
fontPreference: defaultFontPreference,
bodyFontPreference: defaultBodyFontPreference, bodyFontPreference: defaultBodyFontPreference,
headingFontPreference: defaultHeadingFontPreference, headingFontPreference: defaultHeadingFontPreference,
radiusPreference: defaultRadiusPreference, radiusPreference: defaultRadiusPreference,
sidebarStyle: defaultSidebarStyle, sidebarStyle: defaultSidebarStyle,
colorMode: "system", colorMode: fallbackAppearance.colorMode,
colorTheme: "slate", colorTheme: fallbackAppearance.colorTheme,
brandName: defaultBrand.name, brandName: defaultBrand.name,
brandTagline: defaultBrand.tagline, brandTagline: defaultBrand.tagline,
brandLogoText: defaultBrand.logoText, brandLogoText: defaultBrand.logoText,
brandIcon: defaultBrand.icon, brandIcon: defaultBrand.icon,
pdfTemplate: "classic", pdfTemplate: fallbackAppearance.pdfTemplate,
pdfAccentColor: "#111827", pdfAccentColor: fallbackAppearance.pdfAccentColor,
pdfFooterText: "Professional Invoicing", pdfFooterText: fallbackAppearance.pdfFooterText,
pdfShowLogo: true, pdfShowLogo: fallbackAppearance.pdfShowLogo,
pdfShowPageNumbers: true, pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
}; };
const AppearanceContext = createContext<AppearanceContextValue | null>(null); const AppearanceContext = createContext<AppearanceContextValue | null>(null);
@@ -103,7 +113,6 @@ function getServerAppearancePatch(
): AppearancePatch { ): AppearancePatch {
return { return {
interfaceTheme: serverAppearance.interfaceTheme, interfaceTheme: serverAppearance.interfaceTheme,
fontPreference: serverAppearance.fontPreference,
bodyFontPreference: serverAppearance.bodyFontPreference, bodyFontPreference: serverAppearance.bodyFontPreference,
headingFontPreference: serverAppearance.headingFontPreference, headingFontPreference: serverAppearance.headingFontPreference,
radiusPreference: serverAppearance.radiusPreference, radiusPreference: serverAppearance.radiusPreference,
@@ -123,53 +132,6 @@ function getServerAppearancePatch(
}; };
} }
function isInterfaceTheme(value: unknown): value is InterfaceTheme {
return (
value === "beenvoice" ||
value === "shadcn" ||
value === "minimal" ||
value === "editorial"
);
}
function isFontPreference(value: unknown): value is FontPreference {
return (
value === "brand" ||
value === "platform" ||
value === "inter" ||
value === "serif"
);
}
function isColorMode(value: unknown): value is ColorMode {
return value === "light" || value === "dark" || value === "system";
}
function isColorTheme(value: unknown): value is ColorTheme {
return (
value === "slate" ||
value === "blue" ||
value === "green" ||
value === "rose" ||
value === "orange" ||
value === "custom"
);
}
function isRadiusPreference(value: unknown): value is RadiusPreference {
return (
value === "none" ||
value === "sm" ||
value === "md" ||
value === "lg" ||
value === "xl"
);
}
function isSidebarStyle(value: unknown): value is SidebarStyle {
return value === "floating" || value === "docked";
}
function readStoredAppearance(): Partial<AppearancePreferences> | null { function readStoredAppearance(): Partial<AppearancePreferences> | null {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
@@ -179,9 +141,6 @@ function readStoredAppearance(): Partial<AppearancePreferences> | null {
interfaceTheme: isInterfaceTheme(parsed.interfaceTheme) interfaceTheme: isInterfaceTheme(parsed.interfaceTheme)
? parsed.interfaceTheme ? parsed.interfaceTheme
: undefined, : undefined,
fontPreference: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
bodyFontPreference: isFontPreference(parsed.bodyFontPreference) bodyFontPreference: isFontPreference(parsed.bodyFontPreference)
? parsed.bodyFontPreference ? parsed.bodyFontPreference
: isFontPreference(parsed.fontPreference) : isFontPreference(parsed.fontPreference)
@@ -202,8 +161,9 @@ function readStoredAppearance(): Partial<AppearancePreferences> | null {
colorTheme: isColorTheme(parsed.colorTheme) colorTheme: isColorTheme(parsed.colorTheme)
? parsed.colorTheme ? parsed.colorTheme
: undefined, : undefined,
customColor: customColor: isHslChannels(parsed.customColor)
typeof parsed.customColor === "string" ? parsed.customColor : undefined, ? parsed.customColor
: undefined,
brandName: brandName:
typeof parsed.brandName === "string" ? parsed.brandName : undefined, typeof parsed.brandName === "string" ? parsed.brandName : undefined,
brandTagline: brandTagline:
@@ -216,8 +176,7 @@ function readStoredAppearance(): Partial<AppearancePreferences> | null {
: undefined, : undefined,
brandIcon: brandIcon:
typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined, typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined,
pdfTemplate: pdfTemplate: isPdfTemplate(parsed.pdfTemplate)
parsed.pdfTemplate === "classic" || parsed.pdfTemplate === "minimal"
? parsed.pdfTemplate ? parsed.pdfTemplate
: undefined, : undefined,
pdfAccentColor: pdfAccentColor:
@@ -255,7 +214,6 @@ function applyAppearance(prefs: AppearancePreferences) {
const root = document.documentElement; const root = document.documentElement;
root.dataset.interfaceTheme = prefs.interfaceTheme; root.dataset.interfaceTheme = prefs.interfaceTheme;
root.dataset.font = prefs.fontPreference;
root.dataset.bodyFont = prefs.bodyFontPreference; root.dataset.bodyFont = prefs.bodyFontPreference;
root.dataset.headingFont = prefs.headingFontPreference; root.dataset.headingFont = prefs.headingFontPreference;
root.dataset.radius = prefs.radiusPreference; root.dataset.radius = prefs.radiusPreference;
@@ -279,6 +237,8 @@ export function AppearanceProvider({
}) { }) {
const [appearance, setAppearance] = const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance); useState<AppearancePreferences>(defaultAppearance);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
const utils = api.useUtils(); const utils = api.useUtils();
const updateMutation = api.settings.updateTheme.useMutation({ const updateMutation = api.settings.updateTheme.useMutation({
onSuccess: async () => { onSuccess: async () => {
@@ -299,6 +259,38 @@ export function AppearanceProvider({
}, },
}); });
const persistAppearance = useCallback(
(patch: AppearancePatch) => {
if (
patch.customColor !== undefined &&
!isHslChannels(patch.customColor)
) {
return;
}
updateMutation.mutate({
interfaceTheme: patch.interfaceTheme,
bodyFontPreference: patch.bodyFontPreference,
headingFontPreference: patch.headingFontPreference,
radiusPreference: patch.radiusPreference,
sidebarStyle: patch.sidebarStyle,
theme: patch.colorMode,
colorTheme: patch.colorTheme,
customColor: patch.customColor,
brandName: patch.brandName,
brandTagline: patch.brandTagline,
brandLogoText: patch.brandLogoText,
brandIcon: patch.brandIcon,
pdfTemplate: patch.pdfTemplate,
pdfAccentColor: patch.pdfAccentColor,
pdfFooterText: patch.pdfFooterText,
pdfShowLogo: patch.pdfShowLogo,
pdfShowPageNumbers: patch.pdfShowPageNumbers,
});
},
[updateMutation],
);
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, { const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
retry: false, retry: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
@@ -328,6 +320,15 @@ export function AppearanceProvider({
const updateAppearance = useCallback( const updateAppearance = useCallback(
(patch: AppearancePatch) => { (patch: AppearancePatch) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (Object.keys(pendingDebouncedPatchRef.current).length > 0) {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
}
setAppearance((prev) => { setAppearance((prev) => {
const next = { ...prev, ...patch }; const next = { ...prev, ...patch };
applyAppearance(next); applyAppearance(next);
@@ -335,37 +336,61 @@ export function AppearanceProvider({
return next; return next;
}); });
updateMutation.mutate({ persistAppearance(patch);
interfaceTheme: patch.interfaceTheme,
fontPreference: patch.fontPreference,
bodyFontPreference: patch.bodyFontPreference,
headingFontPreference: patch.headingFontPreference,
radiusPreference: patch.radiusPreference,
sidebarStyle: patch.sidebarStyle,
theme: patch.colorMode,
colorTheme: patch.colorTheme,
customColor: patch.customColor,
brandName: patch.brandName,
brandTagline: patch.brandTagline,
brandLogoText: patch.brandLogoText,
brandIcon: patch.brandIcon,
pdfTemplate: patch.pdfTemplate,
pdfAccentColor: patch.pdfAccentColor,
pdfFooterText: patch.pdfFooterText,
pdfShowLogo: patch.pdfShowLogo,
pdfShowPageNumbers: patch.pdfShowPageNumbers,
});
}, },
[updateMutation], [persistAppearance],
);
const updateAppearanceDebounced = useCallback(
(patch: AppearancePatch) => {
pendingDebouncedPatchRef.current = {
...pendingDebouncedPatchRef.current,
...patch,
};
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
debounceTimerRef.current = null;
}, 500);
},
[persistAppearance],
);
useEffect(
() => () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
pendingDebouncedPatchRef.current = {};
},
[],
); );
const value = useMemo<AppearanceContextValue>( const value = useMemo<AppearanceContextValue>(
() => ({ () => ({
...appearance, ...appearance,
updateAppearance, updateAppearance,
updateAppearanceDebounced,
isUpdating: updateMutation.isPending, isUpdating: updateMutation.isPending,
}), }),
[appearance, updateAppearance, updateMutation.isPending], [
appearance,
updateAppearance,
updateAppearanceDebounced,
updateMutation.isPending,
],
); );
return ( return (
+20 -20
View File
@@ -1,15 +1,15 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
import { buttonVariants } from "~/components/ui/button" import { buttonVariants } from "~/components/ui/button";
function AlertDialog({ function AlertDialog({
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} /> return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
} }
function AlertDialogTrigger({ function AlertDialogTrigger({
@@ -17,7 +17,7 @@ function AlertDialogTrigger({
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return ( return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} /> <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
) );
} }
function AlertDialogPortal({ function AlertDialogPortal({
@@ -25,7 +25,7 @@ function AlertDialogPortal({
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return ( return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} /> <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
) );
} }
function AlertDialogOverlay({ function AlertDialogOverlay({
@@ -37,11 +37,11 @@ function AlertDialogOverlay({
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogContent({ function AlertDialogContent({
@@ -55,12 +55,12 @@ function AlertDialogContent({
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
className className,
)} )}
{...props} {...props}
/> />
</AlertDialogPortal> </AlertDialogPortal>
) );
} }
function AlertDialogHeader({ function AlertDialogHeader({
@@ -73,7 +73,7 @@ function AlertDialogHeader({
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogFooter({ function AlertDialogFooter({
@@ -85,11 +85,11 @@ function AlertDialogFooter({
data-slot="alert-dialog-footer" data-slot="alert-dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogTitle({ function AlertDialogTitle({
@@ -102,7 +102,7 @@ function AlertDialogTitle({
className={cn("text-lg font-semibold", className)} className={cn("text-lg font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogDescription({ function AlertDialogDescription({
@@ -115,7 +115,7 @@ function AlertDialogDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogAction({ function AlertDialogAction({
@@ -127,7 +127,7 @@ function AlertDialogAction({
className={cn(buttonVariants(), className)} className={cn(buttonVariants(), className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogCancel({ function AlertDialogCancel({
@@ -139,7 +139,7 @@ function AlertDialogCancel({
className={cn(buttonVariants({ variant: "outline" }), className)} className={cn(buttonVariants({ variant: "outline" }), className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -154,4 +154,4 @@ export {
AlertDialogDescription, AlertDialogDescription,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
} };
+14 -14
View File
@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar" import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
const Avatar = React.forwardRef< const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>, React.ElementRef<typeof AvatarPrimitive.Root>,
@@ -13,12 +13,12 @@ const Avatar = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className className,
)} )}
{...props} {...props}
/> />
)) ));
Avatar.displayName = AvatarPrimitive.Root.displayName Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef< const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>, React.ElementRef<typeof AvatarPrimitive.Image>,
@@ -29,8 +29,8 @@ const AvatarImage = React.forwardRef<
className={cn("aspect-square h-full w-full", className)} className={cn("aspect-square h-full w-full", className)}
{...props} {...props}
/> />
)) ));
AvatarImage.displayName = AvatarPrimitive.Image.displayName AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef< const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>, React.ElementRef<typeof AvatarPrimitive.Fallback>,
@@ -39,12 +39,12 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted", "bg-muted flex h-full w-full items-center justify-center rounded-full",
className className,
)} )}
{...props} {...props}
/> />
)) ));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback } export { Avatar, AvatarImage, AvatarFallback };
+15 -15
View File
@@ -1,11 +1,11 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react" import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} /> return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
} }
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
@@ -14,11 +14,11 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
data-slot="breadcrumb-list" data-slot="breadcrumb-list"
className={cn( className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
@@ -28,7 +28,7 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
className={cn("inline-flex items-center gap-1.5", className)} className={cn("inline-flex items-center gap-1.5", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbLink({ function BreadcrumbLink({
@@ -36,9 +36,9 @@ function BreadcrumbLink({
className, className,
...props ...props
}: React.ComponentProps<"a"> & { }: React.ComponentProps<"a"> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot : "a" const Comp = asChild ? Slot : "a";
return ( return (
<Comp <Comp
@@ -46,7 +46,7 @@ function BreadcrumbLink({
className={cn("hover:text-foreground transition-colors", className)} className={cn("hover:text-foreground transition-colors", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
@@ -59,7 +59,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
className={cn("text-foreground font-normal", className)} className={cn("text-foreground font-normal", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbSeparator({ function BreadcrumbSeparator({
@@ -77,7 +77,7 @@ function BreadcrumbSeparator({
> >
{children ?? <ChevronRight />} {children ?? <ChevronRight />}
</li> </li>
) );
} }
function BreadcrumbEllipsis({ function BreadcrumbEllipsis({
@@ -95,7 +95,7 @@ function BreadcrumbEllipsis({
<MoreHorizontal className="size-4" /> <MoreHorizontal className="size-4" />
<span className="sr-only">More</span> <span className="sr-only">More</span>
</span> </span>
) );
} }
export { export {
@@ -106,4 +106,4 @@ export {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
BreadcrumbEllipsis, BreadcrumbEllipsis,
} };
+47 -41
View File
@@ -1,15 +1,19 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { import {
ChevronDownIcon, ChevronDownIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
} from "lucide-react" } from "lucide-react";
import { DayPicker, getDefaultClassNames, type DayButton } from "react-day-picker" import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
import { Button, buttonVariants } from "~/components/ui/button" import { Button, buttonVariants } from "~/components/ui/button";
function Calendar({ function Calendar({
className, className,
@@ -21,9 +25,9 @@ function Calendar({
components, components,
...props ...props
}: React.ComponentProps<typeof DayPicker> & { }: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"] buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) { }) {
const defaultClassNames = getDefaultClassNames() const defaultClassNames = getDefaultClassNames();
return ( return (
<DayPicker <DayPicker
@@ -32,7 +36,7 @@ function Calendar({
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent", "bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className className,
)} )}
captionLayout={captionLayout} captionLayout={captionLayout}
formatters={{ formatters={{
@@ -44,86 +48,88 @@ function Calendar({
root: cn("w-fit", defaultClassNames.root), root: cn("w-fit", defaultClassNames.root),
months: cn( months: cn(
"flex gap-4 flex-col md:flex-row relative", "flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months defaultClassNames.months,
), ),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month), month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn( nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav defaultClassNames.nav,
), ),
button_previous: cn( button_previous: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous defaultClassNames.button_previous,
), ),
button_next: cn( button_next: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next defaultClassNames.button_next,
), ),
month_caption: cn( month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption defaultClassNames.month_caption,
), ),
dropdowns: cn( dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns defaultClassNames.dropdowns,
), ),
dropdown_root: cn( dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root defaultClassNames.dropdown_root,
), ),
dropdown: cn( dropdown: cn(
"absolute bg-popover inset-0 opacity-0", "absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown defaultClassNames.dropdown,
), ),
caption_label: cn( caption_label: cn(
"select-none font-medium", "select-none font-medium",
captionLayout === "label" captionLayout === "label"
? "text-sm" ? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label defaultClassNames.caption_label,
), ),
table: "w-full border-collapse", table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays), weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn( weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday defaultClassNames.weekday,
), ),
week: cn("flex w-full mt-2", defaultClassNames.week), week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn( week_number_header: cn(
"select-none w-(--cell-size)", "select-none w-(--cell-size)",
defaultClassNames.week_number_header defaultClassNames.week_number_header,
), ),
week_number: cn( week_number: cn(
"text-[0.8rem] select-none text-muted-foreground", "text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number defaultClassNames.week_number,
), ),
day: cn( day: cn(
"relative w-full h-full p-0 text-center group/day aspect-square select-none", "relative w-full h-full p-0 text-center group/day aspect-square select-none",
props.mode !== "single" && "[&:last-child[data-selected=true]_button]:rounded-r-md", props.mode !== "single" &&
props.mode !== "single" && (props.showWeekNumber "[&:last-child[data-selected=true]_button]:rounded-r-md",
props.mode !== "single" &&
(props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md"), : "[&:first-child[data-selected=true]_button]:rounded-l-md"),
defaultClassNames.day defaultClassNames.day,
), ),
range_start: cn( range_start: cn(
"rounded-l-md bg-accent", "rounded-l-md bg-accent",
defaultClassNames.range_start defaultClassNames.range_start,
), ),
range_middle: cn("rounded-none", defaultClassNames.range_middle), range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn( today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today defaultClassNames.today,
), ),
outside: cn( outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground", "text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside defaultClassNames.outside,
), ),
disabled: cn( disabled: cn(
"text-muted-foreground opacity-50", "text-muted-foreground opacity-50",
defaultClassNames.disabled defaultClassNames.disabled,
), ),
hidden: cn("invisible", defaultClassNames.hidden), hidden: cn("invisible", defaultClassNames.hidden),
...classNames, ...classNames,
@@ -137,13 +143,13 @@ function Calendar({
className={cn(className)} className={cn(className)}
{...props} {...props}
/> />
) );
}, },
Chevron: ({ className, orientation, ...props }) => { Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") { if (orientation === "left") {
return ( return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} /> <ChevronLeftIcon className={cn("size-4", className)} {...props} />
) );
} }
if (orientation === "right") { if (orientation === "right") {
@@ -152,12 +158,12 @@ function Calendar({
className={cn("size-4", className)} className={cn("size-4", className)}
{...props} {...props}
/> />
) );
} }
return ( return (
<ChevronDownIcon className={cn("size-4", className)} {...props} /> <ChevronDownIcon className={cn("size-4", className)} {...props} />
) );
}, },
DayButton: CalendarDayButton, DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => { WeekNumber: ({ children, ...props }) => {
@@ -167,13 +173,13 @@ function Calendar({
{children} {children}
</div> </div>
</td> </td>
) );
}, },
...components, ...components,
}} }}
{...props} {...props}
/> />
) );
} }
function CalendarDayButton({ function CalendarDayButton({
@@ -182,12 +188,12 @@ 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(() => {
if (modifiers.focused) ref.current?.focus() if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]) }, [modifiers.focused]);
return ( return (
<Button <Button
@@ -207,11 +213,11 @@ function CalendarDayButton({
className={cn( className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70", "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day, defaultClassNames.day,
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Calendar, CalendarDayButton } export { Calendar, CalendarDayButton };
+1 -1
View File
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-background/80 backdrop-blur-xl border-border/50 text-card-foreground flex flex-col rounded-3xl border shadow-sm overflow-hidden", "bg-background/80 border-border/50 text-card-foreground flex flex-col overflow-hidden rounded-3xl border shadow-sm backdrop-blur-xl",
className, className,
)} )}
{...props} {...props}
+8 -8
View File
@@ -1,10 +1,10 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react" import { CheckIcon } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Checkbox({ function Checkbox({
className, className,
@@ -15,7 +15,7 @@ function Checkbox({
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
{...props} {...props}
> >
@@ -26,7 +26,7 @@ function Checkbox({
<CheckIcon className="size-3.5" /> <CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) );
} }
export { Checkbox } export { Checkbox };
+6 -6
View File
@@ -1,11 +1,11 @@
"use client" "use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({ function Collapsible({
...props ...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) { }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} /> return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
} }
function CollapsibleTrigger({ function CollapsibleTrigger({
@@ -16,7 +16,7 @@ function CollapsibleTrigger({
data-slot="collapsible-trigger" data-slot="collapsible-trigger"
{...props} {...props}
/> />
) );
} }
function CollapsibleContent({ function CollapsibleContent({
@@ -27,7 +27,7 @@ function CollapsibleContent({
data-slot="collapsible-content" data-slot="collapsible-content"
{...props} {...props}
/> />
) );
} }
export { Collapsible, CollapsibleTrigger, CollapsibleContent } export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+13 -2
View File
@@ -3,9 +3,20 @@
import { motion, useSpring, useTransform } from "framer-motion"; import { motion, useSpring, useTransform } from "framer-motion";
import { useEffect } from "react"; import { useEffect } from "react";
export function CountUp({ value, prefix = "", suffix = "" }: { value: number, prefix?: string, suffix?: string }) { export function CountUp({
value,
prefix = "",
suffix = "",
}: {
value: number;
prefix?: string;
suffix?: string;
}) {
const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 }); const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 });
const display = useTransform(spring, (current) => `${prefix}${current.toFixed(2)}${suffix}`); const display = useTransform(
spring,
(current) => `${prefix}${current.toFixed(2)}${suffix}`,
);
useEffect(() => { useEffect(() => {
spring.set(value); spring.set(value);
+12 -3
View File
@@ -66,6 +66,7 @@ export function DatePicker({
: "w-full md:w-32 md:min-w-32"; : "w-full md:w-32 md:min-w-32";
React.useEffect(() => { React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
setValue(formatDate(date)); setValue(formatDate(date));
setMonth(date); setMonth(date);
}, [date]); }, [date]);
@@ -77,7 +78,12 @@ export function DatePicker({
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
className={cn("bg-background pr-10", sizeClasses[size], "w-full", inputClassName)} className={cn(
"bg-background pr-10",
sizeClasses[size],
"w-full",
inputClassName,
)}
onChange={(e) => { onChange={(e) => {
setValue(e.target.value); setValue(e.target.value);
const parsedDate = parseDate(e.target.value); const parsedDate = parseDate(e.target.value);
@@ -98,13 +104,16 @@ export function DatePicker({
<Button <Button
variant="ghost" variant="ghost"
disabled={disabled} disabled={disabled}
className="absolute top-1/2 right-2 size-6 p-0 -translate-y-1/2 text-primary/80 hover:text-primary transition-colors z-20" className="text-primary/80 hover:text-primary absolute top-1/2 right-2 z-20 size-6 -translate-y-1/2 p-0 transition-colors"
> >
<CalendarIcon className="size-4" /> <CalendarIcon className="size-4" />
<span className="sr-only">Select date</span> <span className="sr-only">Select date</span>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0 rounded-xl" align="end"> <PopoverContent
className="w-auto overflow-hidden rounded-xl p-0"
align="end"
>
<Calendar <Calendar
mode="single" mode="single"
selected={date} selected={date}
+20 -20
View File
@@ -1,33 +1,33 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
@@ -39,11 +39,11 @@ function DialogOverlay({
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@@ -52,7 +52,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
@@ -61,7 +61,7 @@ function DialogContent({
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
className className,
)} )}
{...props} {...props}
> >
@@ -77,7 +77,7 @@ function DialogContent({
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -87,7 +87,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -96,11 +96,11 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogTitle({ function DialogTitle({
@@ -113,7 +113,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@@ -126,7 +126,7 @@ function DialogDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -140,4 +140,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };
+2 -2
View File
@@ -25,8 +25,8 @@ export function ImageWithSkeleton({
"duration-700 ease-in-out", "duration-700 ease-in-out",
isLoading isLoading
? "scale-110 blur-2xl grayscale" ? "scale-110 blur-2xl grayscale"
: "scale-100 blur-0 grayscale-0", : "blur-0 scale-100 grayscale-0",
className className,
)} )}
onLoad={() => setIsLoading(false)} onLoad={() => setIsLoading(false)}
alt={alt} alt={alt}
+568
View File
@@ -0,0 +1,568 @@
"use client";
import { useEffect, useState } from "react";
import { HexAlphaColorPicker, HexColorPicker } from "react-colorful";
import { Loader2, PipetteIcon } from "lucide-react";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
hexToRgb,
hexToRgba,
hslToRgb,
hslaToRgba,
rgbToHex,
rgbToHsl,
rgbaToHex,
rgbaToHsla,
} from "~/lib/color-converter";
import { cn } from "~/lib/utils";
declare global {
interface Window {
EyeDropper?: new () => {
open: () => Promise<{ sRGBHex: string }>;
};
}
}
export const colorSchema = z
.string()
.regex(
/^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/,
"Color must be a valid hex color (e.g., #FF0000 or #FF0000FF)",
)
.transform((val) => val.toUpperCase());
interface ColorPickerProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
isLoading?: boolean;
label: string;
error?: string;
className?: string;
alpha?: boolean;
}
interface ColorValues {
hex: string;
rgb: { r: number; g: number; b: number };
hsl: { h: number; s: number; l: number };
rgba?: { r: number; g: number; b: number; a: number };
hsla?: { h: number; s: number; l: number; a: number };
}
export function InputColor({
value,
onChange,
onBlur = () => undefined,
isLoading = false,
label,
error,
className = "mt-6",
alpha = false,
}: ColorPickerProps) {
const [colorFormat, setColorFormat] = useState(alpha ? "HEXA" : "HEX");
const [colorValues, setColorValues] = useState<ColorValues>(() =>
getColorValues(value, alpha),
);
const [hexInputValue, setHexInputValue] = useState(value);
const [hexInputError, setHexInputError] = useState<string | null>(null);
const updateColorValues = (newColor: string) => {
const nextValues = getColorValues(newColor, alpha);
setColorValues(nextValues);
setHexInputValue(newColor.toUpperCase());
};
const handleColorChange = (newColor: string) => {
updateColorValues(newColor);
onChange(newColor.toUpperCase());
};
const handleHexChange = (nextValue: string) => {
let formattedValue = nextValue.toUpperCase();
if (!formattedValue.startsWith("#")) {
formattedValue = `#${formattedValue}`;
}
const maxLength = alpha ? 9 : 7;
if (
formattedValue.length <= maxLength &&
/^#[0-9A-Fa-f]*$/.test(formattedValue)
) {
setHexInputValue(formattedValue);
onChange(formattedValue);
updateColorValues(formattedValue);
try {
if (formattedValue.length === maxLength) {
colorSchema.parse(formattedValue);
setHexInputError(null);
} else {
setHexInputError("Enter a valid color");
}
} catch (validationError) {
if (validationError instanceof z.ZodError) {
setHexInputError("Enter a valid color");
}
}
}
};
const handleRgbChange = (component: "r" | "g" | "b", nextValue: string) => {
const numValue = Number.parseInt(nextValue) || 0;
const clampedValue = Math.max(0, Math.min(255, numValue));
const newRgb = { ...colorValues.rgb, [component]: clampedValue };
const hex = rgbToHex(newRgb.r, newRgb.g, newRgb.b);
const hsl = rgbToHsl(newRgb.r, newRgb.g, newRgb.b);
setColorValues({ ...colorValues, hex, rgb: newRgb, hsl });
setHexInputValue(hex);
onChange(hex);
};
const handleRgbaChange = (
component: "r" | "g" | "b" | "a",
nextValue: string,
) => {
if (!alpha || !colorValues.rgba) return;
const numValue = Number.parseFloat(nextValue) || 0;
const clampedValue =
component === "a"
? Math.max(0, Math.min(1, numValue))
: Math.max(0, Math.min(255, Math.floor(numValue)));
const newRgba = { ...colorValues.rgba, [component]: clampedValue };
const hex = rgbaToHex(newRgba.r, newRgba.g, newRgba.b, newRgba.a);
const hsla = rgbaToHsla(newRgba.r, newRgba.g, newRgba.b, newRgba.a);
setColorValues({
...colorValues,
hex: hex.slice(0, 7),
rgb: { r: newRgba.r, g: newRgba.g, b: newRgba.b },
hsl: rgbToHsl(newRgba.r, newRgba.g, newRgba.b),
rgba: newRgba,
hsla,
});
setHexInputValue(hex);
onChange(hex);
};
const handleHslChange = (component: "h" | "s" | "l", nextValue: string) => {
const numValue = Number.parseInt(nextValue) || 0;
const clampedValue =
component === "h"
? Math.max(0, Math.min(360, numValue))
: Math.max(0, Math.min(100, numValue));
const newHsl = { ...colorValues.hsl, [component]: clampedValue };
const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l);
const hex = rgbToHex(rgb.r, rgb.g, rgb.b);
setColorValues({ ...colorValues, hex, rgb, hsl: newHsl });
setHexInputValue(hex);
onChange(hex);
};
const handleHslaChange = (
component: "h" | "s" | "l" | "a",
nextValue: string,
) => {
if (!alpha || !colorValues.hsla) return;
const numValue = Number.parseFloat(nextValue) || 0;
const clampedValue =
component === "a"
? Math.max(0, Math.min(1, numValue))
: component === "h"
? Math.max(0, Math.min(360, numValue))
: Math.max(0, Math.min(100, numValue));
const newHsla = { ...colorValues.hsla, [component]: clampedValue };
const rgba = hslaToRgba(newHsla.h, newHsla.s, newHsla.l, newHsla.a);
const hex = rgbaToHex(rgba.r, rgba.g, rgba.b, rgba.a);
setColorValues({
...colorValues,
hex: hex.slice(0, 7),
rgb: { r: rgba.r, g: rgba.g, b: rgba.b },
hsl: { h: newHsla.h, s: newHsla.s, l: newHsla.l },
rgba,
hsla: newHsla,
});
setHexInputValue(hex);
onChange(hex);
};
const handlePopoverChange = (open: boolean) => {
if (!open) {
setColorFormat(alpha ? "HEXA" : "HEX");
onBlur();
}
};
const handleEyeDropper = async () => {
const EyeDropper = window.EyeDropper;
if (!EyeDropper) return;
try {
const eyeDropper = new EyeDropper();
const result = await eyeDropper.open();
const pickedColor = result.sRGBHex;
updateColorValues(pickedColor);
onChange(pickedColor);
} catch {
// User canceled the browser picker.
}
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Synchronize controlled color value into the picker fields.
updateColorValues(value);
setHexInputValue(value.toUpperCase());
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateColorValues intentionally derives all picker state from value.
}, [value]);
const getCurrentHexValue = () => {
if (colorFormat === "HEX" || colorFormat === "HEXA") {
return hexInputValue;
}
if (alpha && colorValues.rgba) {
return rgbaToHex(
colorValues.rgba.r,
colorValues.rgba.g,
colorValues.rgba.b,
colorValues.rgba.a,
);
}
return colorValues.hex;
};
return (
<div className={cn(className)}>
<Label className="mb-3">{label}</Label>
<div className="flex items-center gap-4">
<Popover onOpenChange={handlePopoverChange}>
<PopoverTrigger asChild>
<Button
className="border-border relative h-12 w-12 overflow-hidden border shadow-none"
size="icon"
style={{ backgroundColor: hexInputValue }}
type="button"
variant="outline"
>
{alpha && colorValues.rgba && colorValues.rgba.a < 1 && (
<span
className="absolute inset-0 opacity-20"
style={{
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
backgroundSize: "8px 8px",
backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
}}
/>
)}
<span className="sr-only">Open {label} picker</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-3" align="start">
<div className="color-picker space-y-3">
<div className="relative">
<Button
variant="ghost"
size="icon"
className="absolute -top-1.5 -left-1 z-10 flex h-7 w-7 items-center gap-1 bg-transparent hover:bg-transparent"
onClick={handleEyeDropper}
disabled={!isEyeDropperAvailable()}
type="button"
>
<PipetteIcon className="h-3 w-3" />
<span className="sr-only">Pick color from screen</span>
</Button>
{alpha ? (
<HexAlphaColorPicker
className="!aspect-square !h-[244.79px] !w-[244.79px]"
color={value}
onChange={handleColorChange}
/>
) : (
<HexColorPicker
className="!aspect-square !h-[244.79px] !w-[244.79px]"
color={value}
onChange={handleColorChange}
/>
)}
</div>
<div className="flex gap-2">
<Select value={colorFormat} onValueChange={setColorFormat}>
<SelectTrigger className="!h-7 !w-[4.8rem] rounded-sm px-2 py-1 !text-sm">
<SelectValue placeholder="Color" />
</SelectTrigger>
<SelectContent className="min-w-20">
{alpha ? (
<>
<SelectItem value="HEXA" className="h-7 text-sm">
HEXA
</SelectItem>
<SelectItem value="RGBA" className="h-7 text-sm">
RGBA
</SelectItem>
<SelectItem value="HSLA" className="h-7 text-sm">
HSLA
</SelectItem>
</>
) : (
<>
<SelectItem value="HEX" className="h-7 text-sm">
HEX
</SelectItem>
<SelectItem value="RGB" className="h-7 text-sm">
RGB
</SelectItem>
<SelectItem value="HSL" className="h-7 text-sm">
HSL
</SelectItem>
</>
)}
</SelectContent>
</Select>
<ColorFormatFields
alpha={alpha}
colorFormat={colorFormat}
colorValues={colorValues}
currentHexValue={getCurrentHexValue()}
handleHexChange={handleHexChange}
handleHslChange={handleHslChange}
handleHslaChange={handleHslaChange}
handleRgbChange={handleRgbChange}
handleRgbaChange={handleRgbaChange}
/>
</div>
</div>
</PopoverContent>
</Popover>
<div className="relative flex-1 sm:flex-none">
<Input
placeholder={label}
value={getCurrentHexValue()}
onChange={(event) => handleHexChange(event.target.value)}
onBlur={onBlur}
className={cn("h-12 uppercase", error && "border-destructive")}
/>
{isLoading && (
<span className="absolute inset-y-0 right-0 flex items-center pr-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</span>
)}
</div>
</div>
{error && <p className="text-destructive mt-1.5 text-sm">{error}</p>}
{hexInputError && (
<p className="text-destructive mt-1.5 text-sm">{hexInputError}</p>
)}
</div>
);
}
function ColorFormatFields({
alpha,
colorFormat,
colorValues,
currentHexValue,
handleHexChange,
handleRgbChange,
handleRgbaChange,
handleHslChange,
handleHslaChange,
}: {
alpha: boolean;
colorFormat: string;
colorValues: ColorValues;
currentHexValue: string;
handleHexChange: (value: string) => void;
handleRgbChange: (component: "r" | "g" | "b", value: string) => void;
handleRgbaChange: (component: "r" | "g" | "b" | "a", value: string) => void;
handleHslChange: (component: "h" | "s" | "l", value: string) => void;
handleHslaChange: (component: "h" | "s" | "l" | "a", value: string) => void;
}) {
if (colorFormat === "HEX" || colorFormat === "HEXA") {
return (
<Input
className="h-7 w-[160px] rounded-sm text-sm"
value={currentHexValue}
onChange={(event) => handleHexChange(event.target.value)}
placeholder={alpha ? "#FF0000FF" : "#FF0000"}
maxLength={alpha ? 9 : 7}
/>
);
}
if (colorFormat === "RGB") {
return (
<div className="flex items-center">
<Input
className="h-7 w-13 rounded-l-sm rounded-r-none text-center text-sm"
value={colorValues.rgb.r}
onChange={(event) => handleRgbChange("r", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-none border-x-0 text-center text-sm"
value={colorValues.rgb.g}
onChange={(event) => handleRgbChange("g", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-l-none rounded-r-sm text-center text-sm"
value={colorValues.rgb.b}
onChange={(event) => handleRgbChange("b", event.target.value)}
placeholder="255"
maxLength={3}
/>
</div>
);
}
if (colorFormat === "RGBA" && alpha && colorValues.rgba) {
return (
<div className="flex items-center">
<Input
className="h-7 w-10 rounded-l-sm rounded-r-none px-1 text-center text-sm"
value={colorValues.rgba.r}
onChange={(event) => handleRgbaChange("r", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.rgba.g}
onChange={(event) => handleRgbaChange("g", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.rgba.b}
onChange={(event) => handleRgbaChange("b", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-l-none rounded-r-sm px-1 text-center text-sm"
value={colorValues.rgba.a.toFixed(2)}
onChange={(event) => handleRgbaChange("a", event.target.value)}
placeholder="1.00"
maxLength={4}
/>
</div>
);
}
if (colorFormat === "HSL") {
return (
<div className="flex items-center">
<Input
className="h-7 w-13 rounded-l-sm rounded-r-none text-center text-sm"
value={colorValues.hsl.h}
onChange={(event) => handleHslChange("h", event.target.value)}
placeholder="360"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-none border-x-0 text-center text-sm"
value={colorValues.hsl.s}
onChange={(event) => handleHslChange("s", event.target.value)}
placeholder="100"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-l-none rounded-r-sm text-center text-sm"
value={colorValues.hsl.l}
onChange={(event) => handleHslChange("l", event.target.value)}
placeholder="100"
maxLength={3}
/>
</div>
);
}
if (colorFormat === "HSLA" && alpha && colorValues.hsla) {
return (
<div className="flex items-center">
<Input
className="h-7 w-10 rounded-l-sm rounded-r-none px-1 text-center text-sm"
value={colorValues.hsla.h}
onChange={(event) => handleHslaChange("h", event.target.value)}
placeholder="360"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.hsla.s}
onChange={(event) => handleHslaChange("s", event.target.value)}
placeholder="100"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.hsla.l}
onChange={(event) => handleHslaChange("l", event.target.value)}
placeholder="100"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-l-none rounded-r-sm px-1 text-center text-sm"
value={colorValues.hsla.a.toFixed(2)}
onChange={(event) => handleHslaChange("a", event.target.value)}
placeholder="1.00"
maxLength={4}
/>
</div>
);
}
return null;
}
function getColorValues(value: string, alpha: boolean): ColorValues {
if (alpha) {
const rgba = hexToRgba(value);
const hsla = rgbaToHsla(rgba.r, rgba.g, rgba.b, rgba.a);
return {
hex: value.length === 9 ? value.slice(0, 7) : value,
rgb: { r: rgba.r, g: rgba.g, b: rgba.b },
hsl: rgbToHsl(rgba.r, rgba.g, rgba.b),
rgba,
hsla,
};
}
const rgb = hexToRgb(value);
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
return {
hex: value.toUpperCase(),
rgb,
hsl,
};
}
function isEyeDropperAvailable() {
return typeof window !== "undefined" && Boolean(window.EyeDropper);
}
+7 -7
View File
@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Label({ function Label({
className, className,
@@ -14,11 +14,11 @@ function Label({
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };
+25 -25
View File
@@ -1,9 +1,9 @@
import * as React from "react" import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority" import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react" import { ChevronDownIcon } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function NavigationMenu({ function NavigationMenu({
className, className,
@@ -11,7 +11,7 @@ function NavigationMenu({
viewport = true, viewport = true,
...props ...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & { }: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean viewport?: boolean;
}) { }) {
return ( return (
<NavigationMenuPrimitive.Root <NavigationMenuPrimitive.Root
@@ -19,14 +19,14 @@ function NavigationMenu({
data-viewport={viewport} data-viewport={viewport}
className={cn( className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center", "group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
{viewport && <NavigationMenuViewport />} {viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root> </NavigationMenuPrimitive.Root>
) );
} }
function NavigationMenuList({ function NavigationMenuList({
@@ -38,11 +38,11 @@ function NavigationMenuList({
data-slot="navigation-menu-list" data-slot="navigation-menu-list"
className={cn( className={cn(
"group flex flex-1 list-none items-center justify-center gap-1", "group flex flex-1 list-none items-center justify-center gap-1",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function NavigationMenuItem({ function NavigationMenuItem({
@@ -55,12 +55,12 @@ function NavigationMenuItem({
className={cn("relative", className)} className={cn("relative", className)}
{...props} {...props}
/> />
) );
} }
const navigationMenuTriggerStyle = cva( const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-foreground-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1" "group inline-flex h-9 w-max items-center justify-center bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-foreground-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
) );
function NavigationMenuTrigger({ function NavigationMenuTrigger({
className, className,
@@ -79,7 +79,7 @@ function NavigationMenuTrigger({
aria-hidden="true" aria-hidden="true"
/> />
</NavigationMenuPrimitive.Trigger> </NavigationMenuPrimitive.Trigger>
) );
} }
function NavigationMenuContent({ function NavigationMenuContent({
@@ -91,12 +91,12 @@ function NavigationMenuContent({
data-slot="navigation-menu-content" data-slot="navigation-menu-content"
className={cn( className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto", "data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu: group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none", "group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu: group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function NavigationMenuViewport({ function NavigationMenuViewport({
@@ -106,19 +106,19 @@ function NavigationMenuViewport({
return ( return (
<div <div
className={cn( className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center" "absolute top-full left-0 isolate z-50 flex justify-center",
)} )}
> >
<NavigationMenuPrimitive.Viewport <NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport" data-slot="navigation-menu-viewport"
className={cn( className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden border shadow md:w-[var(--radix-navigation-menu-viewport-width)]", "origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className className,
)} )}
{...props} {...props}
/> />
</div> </div>
) );
} }
function NavigationMenuLink({ function NavigationMenuLink({
@@ -130,11 +130,11 @@ function NavigationMenuLink({
data-slot="navigation-menu-link" data-slot="navigation-menu-link"
className={cn( className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-foreground-foreground hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4", "data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-foreground-foreground hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function NavigationMenuIndicator({ function NavigationMenuIndicator({
@@ -146,13 +146,13 @@ function NavigationMenuIndicator({
data-slot="navigation-menu-indicator" data-slot="navigation-menu-indicator"
className={cn( className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden", "data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className className,
)} )}
{...props} {...props}
> >
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" /> <div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator> </NavigationMenuPrimitive.Indicator>
) );
} }
export { export {
@@ -165,4 +165,4 @@ export {
NavigationMenuIndicator, NavigationMenuIndicator,
NavigationMenuViewport, NavigationMenuViewport,
navigationMenuTriggerStyle, navigationMenuTriggerStyle,
} };
+10 -10
View File
@@ -1,20 +1,20 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover" import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Popover({ function Popover({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) { }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} /> return <PopoverPrimitive.Root data-slot="popover" {...props} />;
} }
function PopoverTrigger({ function PopoverTrigger({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
} }
function PopoverContent({ function PopoverContent({
@@ -31,18 +31,18 @@ function PopoverContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) border p-4 shadow-md outline-hidden", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) border p-4 shadow-md outline-hidden",
className className,
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
) );
} }
function PopoverAnchor({ function PopoverAnchor({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
} }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+11 -11
View File
@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
const Separator = React.forwardRef< const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>, React.ElementRef<typeof SeparatorPrimitive.Root>,
@@ -11,21 +11,21 @@ const Separator = React.forwardRef<
>( >(
( (
{ className, orientation = "horizontal", decorative = true, ...props }, { className, orientation = "horizontal", decorative = true, ...props },
ref ref,
) => ( ) => (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
ref={ref} ref={ref}
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border", "bg-border shrink-0",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className className,
)} )}
{...props} {...props}
/> />
) ),
) );
Separator.displayName = SeparatorPrimitive.Root.displayName Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator } export { Separator };
+19 -19
View File
@@ -1,31 +1,31 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog" import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ function SheetTrigger({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
} }
function SheetClose({ function SheetClose({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) { }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
} }
function SheetPortal({ function SheetPortal({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) { }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
} }
function SheetOverlay({ function SheetOverlay({
@@ -37,11 +37,11 @@ function SheetOverlay({
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SheetContent({ function SheetContent({
@@ -50,7 +50,7 @@ function SheetContent({
side = "right", side = "right",
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left";
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
@@ -67,7 +67,7 @@ function SheetContent({
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" && side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className className,
)} )}
{...props} {...props}
> >
@@ -78,7 +78,7 @@ function SheetContent({
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
) );
} }
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -88,7 +88,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -98,7 +98,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetTitle({ function SheetTitle({
@@ -111,7 +111,7 @@ function SheetTitle({
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function SheetDescription({ function SheetDescription({
@@ -124,7 +124,7 @@ function SheetDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -136,4 +136,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };
+3 -14
View File
@@ -4,12 +4,7 @@ function Skeleton({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) { }: React.HTMLAttributes<HTMLDivElement>) {
return ( return <div className={cn("bg-muted animate-pulse", className)} {...props} />;
<div
className={cn("bg-muted animate-pulse ", className)}
{...props}
/>
);
} }
// Modern dashboard skeleton components // Modern dashboard skeleton components
@@ -17,10 +12,7 @@ export function DashboardStatsSkeleton() {
return ( return (
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"> <div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div <div key={i} className="border border-gray-100 bg-white p-6 shadow-sm">
key={i}
className=" border border-gray-100 bg-white p-6 shadow-sm"
>
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<Skeleton className="h-9 w-9" /> <Skeleton className="h-9 w-9" />
<Skeleton className="h-4 w-12" /> <Skeleton className="h-4 w-12" />
@@ -39,10 +31,7 @@ export function DashboardCardsSkeleton() {
return ( return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => ( {Array.from({ length: 2 }).map((_, i) => (
<div <div key={i} className="border border-gray-100 bg-white p-6 shadow-sm">
key={i}
className=" border border-gray-100 bg-white p-6 shadow-sm"
>
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded" /> <Skeleton className="h-5 w-5 rounded" />
+1
View File
@@ -113,6 +113,7 @@ export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
if (lockValue !== null) { if (lockValue !== null) {
// Only update internal & emit if changed // Only update internal & emit if changed
if (!isControlled && internal !== 1) { if (!isControlled && internal !== 1) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Force the uncontrolled slider to the reduced-motion lock value.
setInternal(1); setInternal(1);
} }
if (lastEmittedRef.current !== 1) { if (lastEmittedRef.current !== 1) {
+4 -4
View File
@@ -11,7 +11,7 @@ const Tabs = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<TabsPrimitive.Root <TabsPrimitive.Root
ref={ref} ref={ref}
className={cn("flex flex-col gap-2", className)} className={cn("flex flex-col gap-1", className)}
{...props} {...props}
/> />
)); ));
@@ -24,7 +24,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"bg-muted text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg p-1", "bg-muted text-muted-foreground flex h-9 w-full items-center justify-center rounded-lg p-1",
className, className,
)} )}
{...props} {...props}
@@ -39,7 +39,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow", "ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex flex-1 items-center justify-center rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow",
className, className,
)} )}
{...props} {...props}
@@ -54,7 +54,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content <TabsPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none", "ring-offset-background focus-visible:ring-ring mt-1 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
className, className,
)} )}
{...props} {...props}
+10 -10
View File
@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip" import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
@@ -15,7 +15,7 @@ function TooltipProvider({
delayDuration={delayDuration} delayDuration={delayDuration}
{...props} {...props}
/> />
) );
} }
function Tooltip({ function Tooltip({
@@ -25,13 +25,13 @@ function Tooltip({
<TooltipProvider> <TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} /> <TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider> </TooltipProvider>
) );
} }
function TooltipTrigger({ function TooltipTrigger({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
} }
function TooltipContent({ function TooltipContent({
@@ -47,7 +47,7 @@ function TooltipContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", "bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className className,
)} )}
{...props} {...props}
> >
@@ -55,7 +55,7 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+16 -5
View File
@@ -19,6 +19,7 @@ export const env = createEnv({
.enum(["development", "test", "production"]) .enum(["development", "test", "production"])
.default("development"), .default("development"),
DB_DISABLE_SSL: z.coerce.boolean().optional(), DB_DISABLE_SSL: z.coerce.boolean().optional(),
DISABLE_SIGNUPS: z.coerce.boolean().optional(),
// SSO / Authentik (optional) // SSO / Authentik (optional)
AUTHENTIK_ISSUER: z.string().url().optional(), AUTHENTIK_ISSUER: z.string().url().optional(),
AUTHENTIK_CLIENT_ID: z.string().optional(), AUTHENTIK_CLIENT_ID: z.string().optional(),
@@ -41,18 +42,27 @@ export const env = createEnv({
NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(), NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(),
NEXT_PUBLIC_BRAND_ICON: z.string().optional(), NEXT_PUBLIC_BRAND_ICON: z.string().optional(),
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME: z NEXT_PUBLIC_DEFAULT_INTERFACE_THEME: z
.enum(["beenvoice", "shadcn", "minimal", "editorial"]) .enum([
"beenvoice",
"frutiger",
"frutiger-aero",
"shadcn",
"minimal",
"editorial",
])
.optional(), .optional(),
NEXT_PUBLIC_DEFAULT_FONT: z NEXT_PUBLIC_DEFAULT_FONT: z
.enum(["brand", "platform", "inter", "serif"]) .enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(), .optional(),
NEXT_PUBLIC_DEFAULT_BODY_FONT: z NEXT_PUBLIC_DEFAULT_BODY_FONT: z
.enum(["brand", "platform", "inter", "serif"]) .enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(), .optional(),
NEXT_PUBLIC_DEFAULT_HEADING_FONT: z NEXT_PUBLIC_DEFAULT_HEADING_FONT: z
.enum(["brand", "platform", "inter", "serif"]) .enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_RADIUS: z
.enum(["none", "sm", "md", "lg", "xl"])
.optional(), .optional(),
NEXT_PUBLIC_DEFAULT_RADIUS: z.enum(["none", "sm", "md", "lg", "xl"]).optional(),
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE: z NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE: z
.enum(["floating", "docked"]) .enum(["floating", "docked"])
.optional(), .optional(),
@@ -70,6 +80,7 @@ export const env = createEnv({
RESEND_DOMAIN: process.env.RESEND_DOMAIN, RESEND_DOMAIN: process.env.RESEND_DOMAIN,
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
DB_DISABLE_SSL: process.env.DB_DISABLE_SSL, DB_DISABLE_SSL: process.env.DB_DISABLE_SSL,
DISABLE_SIGNUPS: process.env.DISABLE_SIGNUPS,
AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER, AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER,
AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID,
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET, AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
+1
View File
@@ -39,6 +39,7 @@ export function useCountUp({
useEffect(() => { useEffect(() => {
// Reset when end value changes // Reset when end value changes
// eslint-disable-next-line react-hooks/set-state-in-effect -- Restart the animation from the configured start value when inputs change.
setCount(start); setCount(start);
setIsAnimating(false); setIsAnimating(false);
+126
View File
@@ -0,0 +1,126 @@
import { z } from "zod";
export const interfaceThemeValues = [
"beenvoice",
"frutiger",
"frutiger-aero",
"shadcn",
"minimal",
"editorial",
] as const;
export const fontPreferenceValues = [
"brand",
"frutiger",
"platform",
"inter",
"serif",
] as const;
export const radiusPreferenceValues = ["none", "sm", "md", "lg", "xl"] as const;
export const sidebarStyleValues = ["floating", "docked"] as const;
export const colorModeValues = ["light", "dark", "system"] as const;
export const colorThemeValues = [
"slate",
"blue",
"green",
"rose",
"orange",
"custom",
] as const;
export const pdfTemplateValues = ["classic", "minimal"] as const;
export const interfaceThemeSchema = z.enum(interfaceThemeValues);
export const fontPreferenceSchema = z.enum(fontPreferenceValues);
export const radiusPreferenceSchema = z.enum(radiusPreferenceValues);
export const sidebarStyleSchema = z.enum(sidebarStyleValues);
export const colorModeSchema = z.enum(colorModeValues);
export const colorThemeSchema = z.enum(colorThemeValues);
export const pdfTemplateSchema = z.enum(pdfTemplateValues);
export const hslChannelsSchema = z
.string()
.trim()
.regex(
/^(?:360(?:\.0)?|3[0-5]\d(?:\.\d)?|[12]?\d?\d(?:\.\d)?)\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%$/,
"Use HSL channels like 142.1 76.2% 36.3%",
);
export type InterfaceTheme = z.infer<typeof interfaceThemeSchema>;
export type FontPreference = z.infer<typeof fontPreferenceSchema>;
export type RadiusPreference = z.infer<typeof radiusPreferenceSchema>;
export type SidebarStyle = z.infer<typeof sidebarStyleSchema>;
export type ColorMode = z.infer<typeof colorModeSchema>;
export type ColorTheme = z.infer<typeof colorThemeSchema>;
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
export const fallbackAppearance = {
interfaceTheme: "beenvoice",
fontPreference: "brand",
bodyFontPreference: "brand",
headingFontPreference: "brand",
radiusPreference: "xl",
sidebarStyle: "floating",
colorMode: "system",
colorTheme: "slate",
customColor: undefined,
brandName: "beenvoice",
brandTagline:
"Simple and efficient invoicing for freelancers and small businesses",
brandLogoText: "beenvoice",
brandIcon: "$",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
} satisfies {
interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
colorMode: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
export function isInterfaceTheme(value: unknown): value is InterfaceTheme {
return interfaceThemeSchema.safeParse(value).success;
}
export function isFontPreference(value: unknown): value is FontPreference {
return fontPreferenceSchema.safeParse(value).success;
}
export function isColorMode(value: unknown): value is ColorMode {
return colorModeSchema.safeParse(value).success;
}
export function isColorTheme(value: unknown): value is ColorTheme {
return colorThemeSchema.safeParse(value).success;
}
export function isRadiusPreference(value: unknown): value is RadiusPreference {
return radiusPreferenceSchema.safeParse(value).success;
}
export function isSidebarStyle(value: unknown): value is SidebarStyle {
return sidebarStyleSchema.safeParse(value).success;
}
export function isPdfTemplate(value: unknown): value is PdfTemplate {
return pdfTemplateSchema.safeParse(value).success;
}
export function isHslChannels(value: unknown): value is string {
return hslChannelsSchema.safeParse(value).success;
}
+2
View File
@@ -10,6 +10,7 @@ const authentikEnabled = Boolean(
process.env.AUTHENTIK_CLIENT_ID && process.env.AUTHENTIK_CLIENT_ID &&
process.env.AUTHENTIK_CLIENT_SECRET, process.env.AUTHENTIK_CLIENT_SECRET,
); );
const signupsDisabled = process.env.DISABLE_SIGNUPS === "true";
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
@@ -34,6 +35,7 @@ export const auth = betterAuth({
}), }),
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
disableSignUp: signupsDisabled,
password: { password: {
hash: async (password) => { hash: async (password) => {
const bcrypt = await import("bcryptjs"); const bcrypt = await import("bcryptjs");
+101 -58
View File
@@ -1,17 +1,36 @@
import { env } from "~/env"; import { env } from "~/env";
import {
fallbackAppearance,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type PdfTemplate,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/appearance";
export type InterfaceTheme = "beenvoice" | "shadcn" | "minimal" | "editorial"; export type {
export type FontPreference = "brand" | "platform" | "inter" | "serif"; ColorMode,
export type RadiusPreference = "none" | "sm" | "md" | "lg" | "xl"; ColorTheme,
export type SidebarStyle = "floating" | "docked"; FontPreference,
export type ColorMode = "light" | "dark" | "system"; InterfaceTheme,
export type ColorTheme = PdfTemplate,
| "slate" RadiusPreference,
| "blue" SidebarStyle,
| "green" } from "~/lib/appearance";
| "rose"
| "orange" export {
| "custom"; colorModeSchema,
colorThemeSchema,
fallbackAppearance,
fontPreferenceSchema,
hslChannelsSchema,
interfaceThemeSchema,
pdfTemplateSchema,
radiusPreferenceSchema,
sidebarStyleSchema,
} from "~/lib/appearance";
export const interfaceThemes: { export const interfaceThemes: {
value: InterfaceTheme; value: InterfaceTheme;
@@ -21,7 +40,20 @@ export const interfaceThemes: {
{ {
value: "beenvoice", value: "beenvoice",
label: "beenvoice", label: "beenvoice",
description: "Opinionated brand system with expressive headings.", description:
"Playfair Display headings, Geist body text, and soft product chrome.",
},
{
value: "frutiger",
label: "Frutiger Airport",
description:
"Rectangular blue-and-yellow wayfinding UI with Frutiger typography and docked navigation.",
},
{
value: "frutiger-aero",
label: "Frutiger Aero",
description:
"Glossy sky-and-glass interface with Frutiger typography and softer surfaces.",
}, },
{ {
value: "shadcn", value: "shadcn",
@@ -49,6 +81,8 @@ export const themePresets: Record<
colorTheme: ColorTheme; colorTheme: ColorTheme;
radiusPreference: RadiusPreference; radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle; sidebarStyle: SidebarStyle;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
} }
> = { > = {
beenvoice: { beenvoice: {
@@ -58,6 +92,28 @@ export const themePresets: Record<
colorTheme: "slate", colorTheme: "slate",
radiusPreference: "xl", radiusPreference: "xl",
sidebarStyle: "floating", sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
},
frutiger: {
interfaceTheme: "frutiger",
bodyFontPreference: "frutiger",
headingFontPreference: "frutiger",
colorTheme: "blue",
radiusPreference: "none",
sidebarStyle: "docked",
pdfTemplate: "minimal",
pdfAccentColor: "#003b5c",
},
"frutiger-aero": {
interfaceTheme: "frutiger-aero",
bodyFontPreference: "frutiger",
headingFontPreference: "frutiger",
colorTheme: "blue",
radiusPreference: "lg",
sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#0077be",
}, },
shadcn: { shadcn: {
interfaceTheme: "shadcn", interfaceTheme: "shadcn",
@@ -66,6 +122,8 @@ export const themePresets: Record<
colorTheme: "slate", colorTheme: "slate",
radiusPreference: "md", radiusPreference: "md",
sidebarStyle: "docked", sidebarStyle: "docked",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
}, },
minimal: { minimal: {
interfaceTheme: "minimal", interfaceTheme: "minimal",
@@ -74,6 +132,8 @@ export const themePresets: Record<
colorTheme: "slate", colorTheme: "slate",
radiusPreference: "sm", radiusPreference: "sm",
sidebarStyle: "docked", sidebarStyle: "docked",
pdfTemplate: "minimal",
pdfAccentColor: "#111827",
}, },
editorial: { editorial: {
interfaceTheme: "editorial", interfaceTheme: "editorial",
@@ -82,36 +142,11 @@ export const themePresets: Record<
colorTheme: "rose", colorTheme: "rose",
radiusPreference: "lg", radiusPreference: "lg",
sidebarStyle: "floating", sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#be123c",
}, },
}; };
export const fontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Brand",
description: "Inter body with Playfair headings.",
},
{
value: "platform",
label: "Platform",
description: "Native system fonts for the current OS.",
},
{
value: "inter",
label: "Inter",
description: "Inter for both body and headings.",
},
{
value: "serif",
label: "Editorial",
description: "Serif headings with system body text.",
},
];
export const bodyFontPreferences: { export const bodyFontPreferences: {
value: FontPreference; value: FontPreference;
label: string; label: string;
@@ -119,8 +154,13 @@ export const bodyFontPreferences: {
}[] = [ }[] = [
{ {
value: "brand", value: "brand",
label: "Brand Sans", label: "Geist",
description: "Inter body text for a clean product feel.", description: "Geist body text for the core beenvoice product feel.",
},
{
value: "frutiger",
label: "Frutiger",
description: "Frutiger body text for signage-like operational screens.",
}, },
{ {
value: "platform", value: "platform",
@@ -129,8 +169,8 @@ export const bodyFontPreferences: {
}, },
{ {
value: "inter", value: "inter",
label: "Inter", label: "Geist Legacy",
description: "Inter body text, explicitly selected.", description: "Legacy sans option mapped to Geist for older installs.",
}, },
{ {
value: "serif", value: "serif",
@@ -146,8 +186,13 @@ export const headingFontPreferences: {
}[] = [ }[] = [
{ {
value: "brand", value: "brand",
label: "Brand Serif", label: "Playfair Display",
description: "Playfair headings for the BeenVoice identity.", description: "Playfair Display headings for the beenvoice identity.",
},
{
value: "frutiger",
label: "Frutiger",
description: "Frutiger headings for airport-inspired wayfinding.",
}, },
{ {
value: "platform", value: "platform",
@@ -156,8 +201,8 @@ export const headingFontPreferences: {
}, },
{ {
value: "inter", value: "inter",
label: "Inter", label: "Geist Legacy",
description: "Inter headings for a plain shadcn-style baseline.", description: "Legacy sans option mapped to Geist for older installs.",
}, },
{ {
value: "serif", value: "serif",
@@ -222,10 +267,10 @@ export const colorModes: {
]; ];
export const defaultInterfaceTheme: InterfaceTheme = export const defaultInterfaceTheme: InterfaceTheme =
env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? "beenvoice"; env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? fallbackAppearance.interfaceTheme;
export const defaultFontPreference: FontPreference = export const defaultFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_FONT ?? "brand"; env.NEXT_PUBLIC_DEFAULT_FONT ?? fallbackAppearance.fontPreference;
export const defaultBodyFontPreference: FontPreference = export const defaultBodyFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_BODY_FONT ?? defaultFontPreference; env.NEXT_PUBLIC_DEFAULT_BODY_FONT ?? defaultFontPreference;
@@ -234,16 +279,14 @@ export const defaultHeadingFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_HEADING_FONT ?? defaultFontPreference; env.NEXT_PUBLIC_DEFAULT_HEADING_FONT ?? defaultFontPreference;
export const defaultRadiusPreference: RadiusPreference = export const defaultRadiusPreference: RadiusPreference =
env.NEXT_PUBLIC_DEFAULT_RADIUS ?? "xl"; env.NEXT_PUBLIC_DEFAULT_RADIUS ?? fallbackAppearance.radiusPreference;
export const defaultSidebarStyle: SidebarStyle = export const defaultSidebarStyle: SidebarStyle =
env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? "floating"; env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? fallbackAppearance.sidebarStyle;
export const brand = { export const brand = {
name: env.NEXT_PUBLIC_BRAND_NAME ?? "beenvoice", name: env.NEXT_PUBLIC_BRAND_NAME ?? fallbackAppearance.brandName,
tagline: tagline: env.NEXT_PUBLIC_BRAND_TAGLINE ?? fallbackAppearance.brandTagline,
env.NEXT_PUBLIC_BRAND_TAGLINE ?? logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? fallbackAppearance.brandLogoText,
"Simple and efficient invoicing for freelancers and small businesses", icon: env.NEXT_PUBLIC_BRAND_ICON ?? fallbackAppearance.brandIcon,
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
}; };
+113
View File
@@ -0,0 +1,113 @@
export function hexToRgb(hex: string) {
const normalized = normalizeHex(hex).slice(1, 7);
return {
r: parseInt(normalized.slice(0, 2), 16),
g: parseInt(normalized.slice(2, 4), 16),
b: parseInt(normalized.slice(4, 6), 16),
};
}
export function rgbToHex(r: number, g: number, b: number) {
return `#${[r, g, b]
.map((channel) => clamp(channel, 0, 255).toString(16).padStart(2, "0"))
.join("")}`.toUpperCase();
}
export function rgbToHsl(r: number, g: number, b: number) {
const red = clamp(r, 0, 255) / 255;
const green = clamp(g, 0, 255) / 255;
const blue = clamp(b, 0, 255) / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const lightness = (max + min) / 2;
const delta = max - min;
if (delta === 0) {
return { h: 0, s: 0, l: Math.round(lightness * 100) };
}
const saturation = delta / (1 - Math.abs(2 * lightness - 1));
const hue =
max === red
? 60 * (((green - blue) / delta) % 6)
: max === green
? 60 * ((blue - red) / delta + 2)
: 60 * ((red - green) / delta + 4);
return {
h: Math.round((hue + 360) % 360),
s: Math.round(saturation * 100),
l: Math.round(lightness * 100),
};
}
export function hslToRgb(h: number, s: number, l: number) {
const hue = clamp(h, 0, 360);
const saturation = clamp(s, 0, 100) / 100;
const lightness = clamp(l, 0, 100) / 100;
const c = (1 - Math.abs(2 * lightness - 1)) * saturation;
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
const m = lightness - c / 2;
const [red, green, blue] =
hue < 60
? [c, x, 0]
: hue < 120
? [x, c, 0]
: hue < 180
? [0, c, x]
: hue < 240
? [0, x, c]
: hue < 300
? [x, 0, c]
: [c, 0, x];
return {
r: Math.round((red + m) * 255),
g: Math.round((green + m) * 255),
b: Math.round((blue + m) * 255),
};
}
export function hexToRgba(hex: string) {
const normalized = normalizeHex(hex, true);
const rgb = hexToRgb(normalized);
const alphaHex = normalized.length === 9 ? normalized.slice(7, 9) : "ff";
return {
...rgb,
a: Number((parseInt(alphaHex, 16) / 255).toFixed(2)),
};
}
export function rgbaToHex(r: number, g: number, b: number, a: number) {
const alpha = clamp(Math.round(clampAlpha(a) * 255), 0, 255)
.toString(16)
.padStart(2, "0");
return `${rgbToHex(r, g, b)}${alpha}`.toUpperCase();
}
export function rgbaToHsla(r: number, g: number, b: number, a: number) {
return { ...rgbToHsl(r, g, b), a: clampAlpha(a) };
}
export function hslaToRgba(h: number, s: number, l: number, a: number) {
return { ...hslToRgb(h, s, l), a: clampAlpha(a) };
}
function normalizeHex(hex: string, alpha = false) {
const fallback = alpha ? "#FFFFFFff" : "#FFFFFF";
const withHash = hex.startsWith("#") ? hex : `#${hex}`;
if (/^#[0-9A-Fa-f]{6}$/.test(withHash)) return withHash;
if (alpha && /^#[0-9A-Fa-f]{8}$/.test(withHash)) return withHash;
return fallback;
}
function clamp(value: number, min: number, max: number) {
return Math.max(
min,
Math.min(max, Math.floor(Number.isFinite(value) ? value : min)),
);
}
function clampAlpha(value: number) {
return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 1));
}
+10 -5
View File
@@ -36,7 +36,8 @@ export function generateAccentColors(hex: string) {
"--popover": `oklch(1 ${base.c * 0.02} ${base.h})`, "--popover": `oklch(1 ${base.c * 0.02} ${base.h})`,
"--popover-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`, "--popover-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--primary": `oklch(0.6 ${base.c} ${base.h})`, "--primary": `oklch(0.6 ${base.c} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2 "--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`, } ${base.h})`,
"--secondary": `oklch(0.9 ${base.c * 0.4} ${base.h})`, "--secondary": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
"--secondary-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`, "--secondary-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
@@ -56,7 +57,8 @@ export function generateAccentColors(hex: string) {
"--sidebar": `oklch(0.98 ${base.c * 0.05} ${base.h})`, "--sidebar": `oklch(0.98 ${base.c * 0.05} ${base.h})`,
"--sidebar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`, "--sidebar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--sidebar-primary": `oklch(0.6 ${base.c} ${base.h})`, "--sidebar-primary": `oklch(0.6 ${base.c} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2 "--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`, } ${base.h})`,
"--sidebar-accent": `oklch(0.9 ${base.c * 0.4} ${base.h})`, "--sidebar-accent": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
"--sidebar-accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`, "--sidebar-accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
@@ -75,10 +77,12 @@ export function generateAccentColors(hex: string) {
"--popover": `oklch(0.17 ${base.c * 0.2} ${base.h})`, "--popover": `oklch(0.17 ${base.c * 0.2} ${base.h})`,
"--popover-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`, "--popover-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--primary": `oklch(0.7 ${base.c} ${base.h})`, "--primary": `oklch(0.7 ${base.c} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2 "--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`, } ${base.h})`,
"--secondary": `oklch(0.3 ${base.c * 0.7} ${base.h})`, "--secondary": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
"--secondary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2 "--secondary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`, } ${base.h})`,
"--muted": `oklch(0.25 ${base.c * 0.3} ${base.h})`, "--muted": `oklch(0.25 ${base.c * 0.3} ${base.h})`,
"--muted-foreground": `oklch(0.7 ${base.c * 0.2} ${base.h})`, "--muted-foreground": `oklch(0.7 ${base.c * 0.2} ${base.h})`,
@@ -96,7 +100,8 @@ export function generateAccentColors(hex: string) {
"--sidebar": `oklch(0.1 ${base.c * 0.1} ${base.h})`, "--sidebar": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--sidebar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`, "--sidebar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--sidebar-primary": `oklch(0.7 ${base.c} ${base.h})`, "--sidebar-primary": `oklch(0.7 ${base.c} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2 "--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`, } ${base.h})`,
"--sidebar-accent": `oklch(0.3 ${base.c * 0.7} ${base.h})`, "--sidebar-accent": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
"--sidebar-accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`, "--sidebar-accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
+2 -6
View File
@@ -6,7 +6,7 @@ interface InvoiceEmailTemplateProps {
status: string; status: string;
totalAmount: number; totalAmount: number;
taxRate: number; taxRate: number;
notes?: string | null; currency?: string | null;
client: { client: {
name: string; name: string;
email: string | null; email: string | null;
@@ -57,7 +57,7 @@ export function generateInvoiceEmailTemplate({
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: invoice.currency ?? "USD",
}).format(amount); }).format(amount);
}; };
@@ -459,8 +459,6 @@ export function generateInvoiceEmailTemplate({
</div> </div>
</div> </div>
<div class="attachment-notice"> <div class="attachment-notice">
<div class="attachment-icon"></div> <div class="attachment-icon"></div>
<div class="attachment-text"> <div class="attachment-text">
@@ -540,8 +538,6 @@ Subtotal: ${formatCurrency(subtotal)}${
} }
Total: ${formatCurrency(total)} Total: ${formatCurrency(total)}
ATTACHMENT ATTACHMENT
═══════════════ ═══════════════
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
+6
View File
@@ -6,6 +6,7 @@ import {
Building, Building,
Receipt, Receipt,
BarChart2, BarChart2,
Shield,
} from "lucide-react"; } from "lucide-react";
export interface NavLink { export interface NavLink {
@@ -35,6 +36,11 @@ export const navigationConfig: NavSection[] = [
title: "Account", title: "Account",
links: [ links: [
{ name: "Settings", href: "/dashboard/settings", icon: Settings }, { name: "Settings", href: "/dashboard/settings", icon: Settings },
{
name: "Administration",
href: "/dashboard/administration",
icon: Shield,
},
], ],
}, },
]; ];
+559 -237
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
+6
View File
@@ -4,6 +4,12 @@ import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if (pathname === "/auth/register" && process.env.DISABLE_SIGNUPS === "true") {
const signInUrl = new URL("/auth/signin", request.url);
signInUrl.searchParams.set("signup", "disabled");
return NextResponse.redirect(signInUrl);
}
// Define public routes that don't require authentication // Define public routes that don't require authentication
const publicRoutes = ["/", "/auth/signin", "/auth/register"]; const publicRoutes = ["/", "/auth/signin", "/auth/register"];
+9 -4
View File
@@ -32,10 +32,11 @@ export const dashboardRouter = createTRPCRouter({
); );
// Helper to check status // Helper to check status
const getStatus = (inv: typeof userInvoices[0]) => { const getStatus = (inv: (typeof userInvoices)[0]) => {
if (inv.status === "paid") return "paid"; if (inv.status === "paid") return "paid";
if (inv.status === "draft") return "draft"; if (inv.status === "draft") return "draft";
if (new Date(inv.dueDate) < now && inv.status !== "paid") return "overdue"; if (new Date(inv.dueDate) < now && inv.status !== "paid")
return "overdue";
return "sent"; return "sent";
}; };
@@ -57,7 +58,10 @@ export const dashboardRouter = createTRPCRouter({
if (issueDate >= currentMonthStart) { if (issueDate >= currentMonthStart) {
currentMonthRevenue += amount; currentMonthRevenue += amount;
} else if (issueDate >= lastMonthStart && issueDate < currentMonthStart) { } else if (
issueDate >= lastMonthStart &&
issueDate < currentMonthStart
) {
lastMonthRevenue += amount; lastMonthRevenue += amount;
} }
} else if (status === "sent" || status === "overdue") { } else if (status === "sent" || status === "overdue") {
@@ -115,7 +119,8 @@ export const dashboardRouter = createTRPCRouter({
pendingAmount, pendingAmount,
overdueCount, overdueCount,
totalClients: userClientsCount, totalClients: userClientsCount,
revenueChange: lastMonthRevenue > 0 revenueChange:
lastMonthRevenue > 0
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100 ? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
: 0, : 0,
revenueChartData, revenueChartData,
+34 -2
View File
@@ -7,6 +7,32 @@ import { env } from "~/env";
import { generateInvoicePDFBlob } from "~/lib/pdf-export"; import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
function plainTextToHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\n/g, "<br>");
}
function normalizeEmailNoteHtml(value: string) {
const visibleText = value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;|\u00a0/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
return visibleText ? value.trim() : "";
}
export const emailRouter = createTRPCRouter({ export const emailRouter = createTRPCRouter({
sendInvoice: protectedProcedure sendInvoice: protectedProcedure
.input( .input(
@@ -95,6 +121,12 @@ export const emailRouter = createTRPCRouter({
"Your Name"; "Your Name";
const userEmail = const userEmail =
invoice.business?.email ?? ctx.session.user?.email ?? ""; invoice.business?.email ?? ctx.session.user?.email ?? "";
const customMessage =
input.customMessage !== undefined
? normalizeEmailNoteHtml(input.customMessage)
: invoice.emailMessage
? plainTextToHtml(invoice.emailMessage)
: undefined;
// Generate branded email template // Generate branded email template
const emailTemplate = generateInvoiceEmailTemplate({ const emailTemplate = generateInvoiceEmailTemplate({
@@ -105,7 +137,7 @@ export const emailRouter = createTRPCRouter({
status: invoice.status, status: invoice.status,
totalAmount: invoice.totalAmount, totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate, taxRate: invoice.taxRate,
notes: invoice.notes, currency: invoice.currency,
client: { client: {
name: invoice.client.name, name: invoice.client.name,
email: invoice.client.email, email: invoice.client.email,
@@ -114,7 +146,7 @@ export const emailRouter = createTRPCRouter({
items: invoice.items, items: invoice.items,
}, },
customContent: input.customContent, customContent: input.customContent,
customMessage: input.customMessage, customMessage,
userName, userName,
userEmail, userEmail,
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000", baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
+37 -16
View File
@@ -47,7 +47,10 @@ export const expensesRouter = createTRPCRouter({
}); });
if (!expense) { if (!expense) {
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "Expense not found",
});
} }
return expense; return expense;
@@ -58,11 +61,11 @@ export const expensesRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const clean = { const clean = {
...input, ...input,
clientId: input.clientId?.trim() || null, clientId: input.clientId?.trim() ?? null,
businessId: input.businessId?.trim() || null, businessId: input.businessId?.trim() ?? null,
invoiceId: input.invoiceId?.trim() || null, invoiceId: input.invoiceId?.trim() ?? null,
category: input.category?.trim() || null, category: input.category?.trim() ?? null,
notes: input.notes?.trim() || null, notes: input.notes?.trim() ?? null,
}; };
if (clean.clientId) { if (clean.clientId) {
@@ -72,7 +75,11 @@ export const expensesRouter = createTRPCRouter({
eq(clients.createdById, ctx.session.user.id), eq(clients.createdById, ctx.session.user.id),
), ),
}); });
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" }); if (!client)
throw new TRPCError({
code: "FORBIDDEN",
message: "Client not found",
});
} }
if (clean.businessId) { if (clean.businessId) {
@@ -82,7 +89,11 @@ export const expensesRouter = createTRPCRouter({
eq(businesses.createdById, ctx.session.user.id), eq(businesses.createdById, ctx.session.user.id),
), ),
}); });
if (!business) throw new TRPCError({ code: "FORBIDDEN", message: "Business not found" }); if (!business)
throw new TRPCError({
code: "FORBIDDEN",
message: "Business not found",
});
} }
if (clean.invoiceId) { if (clean.invoiceId) {
@@ -92,7 +103,11 @@ export const expensesRouter = createTRPCRouter({
eq(invoices.createdById, ctx.session.user.id), eq(invoices.createdById, ctx.session.user.id),
), ),
}); });
if (!invoice) throw new TRPCError({ code: "FORBIDDEN", message: "Invoice not found" }); if (!invoice)
throw new TRPCError({
code: "FORBIDDEN",
message: "Invoice not found",
});
} }
const [expense] = await ctx.db const [expense] = await ctx.db
@@ -116,16 +131,19 @@ export const expensesRouter = createTRPCRouter({
}); });
if (!existing) { if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "Expense not found",
});
} }
const clean = { const clean = {
...data, ...data,
clientId: data.clientId?.trim() || null, clientId: data.clientId?.trim() ?? null,
businessId: data.businessId?.trim() || null, businessId: data.businessId?.trim() ?? null,
invoiceId: data.invoiceId?.trim() || null, invoiceId: data.invoiceId?.trim() ?? null,
category: data.category?.trim() || null, category: data.category?.trim() ?? null,
notes: data.notes?.trim() || null, notes: data.notes?.trim() ?? null,
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -145,7 +163,10 @@ export const expensesRouter = createTRPCRouter({
}); });
if (!existing) { if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "Expense not found",
});
} }
await ctx.db.delete(expenses).where(eq(expenses.id, input.id)); await ctx.db.delete(expenses).where(eq(expenses.id, input.id));
+8 -2
View File
@@ -72,7 +72,10 @@ export const invoiceTemplatesRouter = createTRPCRouter({
}); });
if (!existing) { if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "Template not found",
});
} }
// If setting as default, unset others of same type // If setting as default, unset others of same type
@@ -108,7 +111,10 @@ export const invoiceTemplatesRouter = createTRPCRouter({
}); });
if (!existing) { if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" }); throw new TRPCError({
code: "NOT_FOUND",
message: "Template not found",
});
} }
await ctx.db await ctx.db
+191 -104
View File
@@ -6,8 +6,16 @@ import {
invoiceItems, invoiceItems,
clients, clients,
businesses, businesses,
platformSettings,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import type { db } from "~/server/db";
type InvoiceRouterContext = {
db: typeof db;
session: { user: { id: string } };
};
const invoiceItemSchema = z.object({ const invoiceItemSchema = z.object({
date: z.date(), date: z.date(),
@@ -29,6 +37,7 @@ const createInvoiceSchema = z.object({
dueDate: z.date(), dueDate: z.date(),
status: z.enum(["draft", "sent", "paid"]).default("draft"), status: z.enum(["draft", "sent", "paid"]).default("draft"),
notes: z.string().optional().or(z.literal("")), notes: z.string().optional().or(z.literal("")),
emailMessage: z.string().optional().or(z.literal("")),
taxRate: z.number().min(0).max(100).default(0), taxRate: z.number().min(0).max(100).default(0),
currency: z.string().length(3).default("USD"), currency: z.string().length(3).default("USD"),
items: z.array(invoiceItemSchema).min(1, "At least one item is required"), items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
@@ -43,6 +52,64 @@ const updateStatusSchema = z.object({
status: z.enum(["draft", "sent", "paid"]), status: z.enum(["draft", "sent", "paid"]),
}); });
async function verifyBusinessAccess(
ctx: InvoiceRouterContext,
businessId?: string | null,
) {
if (!businessId) return null;
const business = await ctx.db.query.businesses.findFirst({
where: eq(businesses.id, businessId),
});
if (!business) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Business not found",
});
}
if (business.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to use this business",
});
}
return business;
}
async function verifyClientAccess(ctx: InvoiceRouterContext, clientId: string) {
const client = await ctx.db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Client not found",
});
}
if (client.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to use this client",
});
}
return client;
}
const calculateInvoiceTotal = (
items: Array<z.infer<typeof invoiceItemSchema>>,
taxRate: number,
) => {
const subtotal = items.reduce((sum, item) => sum + item.hours * item.rate, 0);
const taxAmount = (subtotal * taxRate) / 100;
return subtotal + taxAmount;
};
export const invoicesRouter = createTRPCRouter({ export const invoicesRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => { getAll: protectedProcedure.query(async ({ ctx }) => {
try { try {
@@ -140,62 +207,33 @@ export const invoicesRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { try {
const { items, ...invoiceData } = input; const { items, ...invoiceData } = input;
const cleanInvoiceData = {
...invoiceData,
businessId:
!invoiceData.businessId || invoiceData.businessId.trim() === ""
? null
: invoiceData.businessId,
notes: invoiceData.notes === "" ? null : invoiceData.notes,
emailMessage:
invoiceData.emailMessage === "" ? null : invoiceData.emailMessage,
};
// Verify business exists and belongs to user (if provided) // Verify business exists and belongs to user (if provided)
if (invoiceData.businessId && invoiceData.businessId.trim() !== "") { await verifyBusinessAccess(ctx, cleanInvoiceData.businessId);
const business = await ctx.db.query.businesses.findFirst({
where: eq(businesses.id, invoiceData.businessId),
});
if (!business) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Business not found",
});
}
if (business.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"You don't have permission to create invoices for this business",
});
}
}
// Verify client exists and belongs to user // Verify client exists and belongs to user
const client = await ctx.db.query.clients.findFirst({ await verifyClientAccess(ctx, cleanInvoiceData.clientId);
where: eq(clients.id, invoiceData.clientId),
});
if (!client) { const totalAmount = calculateInvoiceTotal(
throw new TRPCError({ items,
code: "BAD_REQUEST", cleanInvoiceData.taxRate,
message: "Client not found",
});
}
if (client.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"You don't have permission to create invoices for this client",
});
}
// Calculate subtotal and tax
const subtotal = items.reduce(
(sum, item) => sum + item.hours * item.rate,
0,
); );
const taxAmount = (subtotal * invoiceData.taxRate) / 100;
const totalAmount = subtotal + taxAmount;
// Create invoice return await ctx.db.transaction(async (tx) => {
const [invoice] = await ctx.db const [invoice] = await tx
.insert(invoices) .insert(invoices)
.values({ .values({
...invoiceData, ...cleanInvoiceData,
totalAmount, totalAmount,
createdById: ctx.session.user.id, createdById: ctx.session.user.id,
}) })
@@ -208,17 +246,17 @@ export const invoicesRouter = createTRPCRouter({
}); });
} }
// Create invoice items await tx.insert(invoiceItems).values(
const itemsToInsert = items.map((item, idx) => ({ items.map((item, idx) => ({
...item, ...item,
invoiceId: invoice.id, invoiceId: invoice.id,
amount: item.hours * item.rate, amount: item.hours * item.rate,
position: idx, position: idx,
})); })),
);
await ctx.db.insert(invoiceItems).values(itemsToInsert);
return invoice; return invoice;
});
} catch (error) { } catch (error) {
if (error instanceof TRPCError) throw error; if (error instanceof TRPCError) throw error;
throw new TRPCError({ throw new TRPCError({
@@ -238,11 +276,25 @@ export const invoicesRouter = createTRPCRouter({
// Clean up empty strings to null for optional string fields only // Clean up empty strings to null for optional string fields only
const cleanInvoiceData = { const cleanInvoiceData = {
...invoiceData, ...invoiceData,
...(invoiceData.businessId !== undefined
? {
businessId: businessId:
!invoiceData.businessId || invoiceData.businessId.trim() === "" invoiceData.businessId.trim() === ""
? null ? null
: invoiceData.businessId, : invoiceData.businessId,
notes: invoiceData.notes === "" ? null : invoiceData.notes, }
: {}),
...(invoiceData.notes !== undefined
? { notes: invoiceData.notes === "" ? null : invoiceData.notes }
: {}),
...(invoiceData.emailMessage !== undefined
? {
emailMessage:
invoiceData.emailMessage === ""
? null
: invoiceData.emailMessage,
}
: {}),
}; };
// Verify invoice exists and belongs to user // Verify invoice exists and belongs to user
@@ -269,53 +321,28 @@ export const invoicesRouter = createTRPCRouter({
cleanInvoiceData.businessId && cleanInvoiceData.businessId &&
cleanInvoiceData.businessId.trim() !== "" cleanInvoiceData.businessId.trim() !== ""
) { ) {
const business = await ctx.db.query.businesses.findFirst({ await verifyBusinessAccess(ctx, cleanInvoiceData.businessId);
where: eq(businesses.id, cleanInvoiceData.businessId),
});
if (!business || business.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to use this business",
});
}
} }
// If client is being updated, verify it belongs to user // If client is being updated, verify it belongs to user
if (cleanInvoiceData.clientId) { if (cleanInvoiceData.clientId) {
const client = await ctx.db.query.clients.findFirst({ await verifyClientAccess(ctx, cleanInvoiceData.clientId);
where: eq(clients.id, cleanInvoiceData.clientId),
});
if (!client || client.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to use this client",
});
}
} }
await ctx.db.transaction(async (tx) => {
if (items) { if (items) {
// Calculate subtotal and tax const totalAmount = calculateInvoiceTotal(
const subtotal = items.reduce( items,
(sum, item) => sum + item.hours * item.rate, cleanInvoiceData.taxRate ?? existingInvoice.taxRate,
0,
); );
const taxAmount =
(subtotal * (cleanInvoiceData.taxRate ?? existingInvoice.taxRate)) /
100;
const totalAmount = subtotal + taxAmount;
// Update invoice const [updatedInvoice] = await tx
const updateData = { .update(invoices)
.set({
...cleanInvoiceData, ...cleanInvoiceData,
totalAmount, totalAmount,
updatedAt: new Date(), updatedAt: new Date(),
}; })
const [updatedInvoice] = await ctx.db
.update(invoices)
.set(updateData)
.where(eq(invoices.id, id)) .where(eq(invoices.id, id))
.returning(); .returning();
@@ -326,29 +353,23 @@ export const invoicesRouter = createTRPCRouter({
}); });
} }
// Delete existing items and create new ones await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
await ctx.db
.delete(invoiceItems)
.where(eq(invoiceItems.invoiceId, id));
const itemsToInsert = items.map((item, idx) => ({ await tx.insert(invoiceItems).values(
items.map((item, idx) => ({
...item, ...item,
invoiceId: id, invoiceId: id,
amount: item.hours * item.rate, amount: item.hours * item.rate,
position: idx, position: idx,
})); })),
);
await ctx.db.insert(invoiceItems).values(itemsToInsert);
} else { } else {
// Update invoice without items const [updatedInvoice] = await tx
const updateData = { .update(invoices)
.set({
...cleanInvoiceData, ...cleanInvoiceData,
updatedAt: new Date(), updatedAt: new Date(),
}; })
const [updatedInvoice] = await ctx.db
.update(invoices)
.set(updateData)
.where(eq(invoices.id, id)) .where(eq(invoices.id, id))
.returning(); .returning();
@@ -359,6 +380,7 @@ export const invoicesRouter = createTRPCRouter({
}); });
} }
} }
});
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@@ -505,4 +527,69 @@ export const invoicesRouter = createTRPCRouter({
return { success: true, deleted: ownedIds.length }; return { success: true, deleted: ownedIds.length };
}), }),
previewPdf: protectedProcedure
.input(createInvoiceSchema)
.query(async ({ ctx, input }) => {
try {
const businessId =
input.businessId && input.businessId.trim() !== ""
? input.businessId
: null;
const [client, business, settings] = await Promise.all([
verifyClientAccess(ctx, input.clientId),
verifyBusinessAccess(ctx, businessId),
ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
}),
]);
const totalAmount = calculateInvoiceTotal(input.items, input.taxRate);
const pdfBlob = await generateInvoicePDFBlob(
{
invoiceNumber: input.invoiceNumber,
invoicePrefix: input.invoicePrefix,
issueDate: input.issueDate,
dueDate: input.dueDate,
status: input.status,
totalAmount,
taxRate: input.taxRate,
currency: input.currency,
notes: input.notes,
client,
business,
items: input.items.map((item) => ({
date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.hours * item.rate,
})),
},
{
pdfTemplate: settings?.pdfTemplate as
| "classic"
| "minimal"
| undefined,
pdfAccentColor: settings?.pdfAccentColor,
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
},
);
const buffer = Buffer.from(await pdfBlob.arrayBuffer());
return {
contentType: "application/pdf",
base64: buffer.toString("base64"),
};
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate PDF preview",
cause: error,
});
}
}),
}); });
+84 -52
View File
@@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { import {
@@ -8,6 +8,7 @@ import {
publicProcedure, publicProcedure,
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import { import {
accounts,
users, users,
clients, clients,
businesses, businesses,
@@ -16,12 +17,20 @@ import {
platformSettings, platformSettings,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { import {
colorModeSchema,
colorThemeSchema,
defaultBodyFontPreference, defaultBodyFontPreference,
defaultFontPreference,
defaultHeadingFontPreference, defaultHeadingFontPreference,
defaultInterfaceTheme, defaultInterfaceTheme,
defaultRadiusPreference, defaultRadiusPreference,
defaultSidebarStyle, defaultSidebarStyle,
fallbackAppearance,
fontPreferenceSchema,
hslChannelsSchema,
interfaceThemeSchema,
pdfTemplateSchema,
radiusPreferenceSchema,
sidebarStyleSchema,
type ColorMode, type ColorMode,
type ColorTheme, type ColorTheme,
type FontPreference, type FontPreference,
@@ -29,9 +38,10 @@ import {
type RadiusPreference, type RadiusPreference,
type SidebarStyle, type SidebarStyle,
} from "~/lib/branding"; } from "~/lib/branding";
import type { db as database } from "~/server/db";
async function requireAdmin(ctx: { async function requireAdmin(ctx: {
db: typeof import("~/server/db").db; db: typeof database;
session: { user: { id: string } }; session: { user: { id: string } };
}) { }) {
const user = await ctx.db.query.users.findFirst({ const user = await ctx.db.query.users.findFirst({
@@ -94,6 +104,7 @@ const InvoiceBackupSchema = z.object({
totalAmount: z.number().default(0), totalAmount: z.number().default(0),
taxRate: z.number().default(0), taxRate: z.number().default(0),
notes: z.string().optional(), notes: z.string().optional(),
emailMessage: z.string().optional(),
items: z.array(InvoiceItemBackupSchema), items: z.array(InvoiceItemBackupSchema),
}); });
@@ -216,12 +227,12 @@ export const settingsRouter = createTRPCRouter({
}); });
return { return {
colorTheme: (settings?.colorTheme as ColorTheme) ?? "slate", colorTheme:
(settings?.colorTheme as ColorTheme) ?? fallbackAppearance.colorTheme,
customColor: settings?.customColor ?? undefined, customColor: settings?.customColor ?? undefined,
theme: (settings?.theme as ColorMode) ?? "system", theme: (settings?.theme as ColorMode) ?? fallbackAppearance.colorMode,
interfaceTheme: interfaceTheme:
(settings?.interfaceTheme as InterfaceTheme) ?? defaultInterfaceTheme, (settings?.interfaceTheme as InterfaceTheme) ?? defaultInterfaceTheme,
fontPreference: defaultFontPreference,
bodyFontPreference: bodyFontPreference:
(settings?.bodyFontPreference as FontPreference) ?? (settings?.bodyFontPreference as FontPreference) ??
defaultBodyFontPreference, defaultBodyFontPreference,
@@ -233,18 +244,21 @@ export const settingsRouter = createTRPCRouter({
defaultRadiusPreference, defaultRadiusPreference,
sidebarStyle: sidebarStyle:
(settings?.sidebarStyle as SidebarStyle) ?? defaultSidebarStyle, (settings?.sidebarStyle as SidebarStyle) ?? defaultSidebarStyle,
brandName: settings?.brandName ?? "beenvoice", brandName: settings?.brandName ?? fallbackAppearance.brandName,
brandTagline: brandTagline: settings?.brandTagline ?? fallbackAppearance.brandTagline,
settings?.brandTagline ?? brandLogoText:
"Simple and efficient invoicing for freelancers and small businesses", settings?.brandLogoText ?? fallbackAppearance.brandLogoText,
brandLogoText: settings?.brandLogoText ?? "beenvoice", brandIcon: settings?.brandIcon ?? fallbackAppearance.brandIcon,
brandIcon: settings?.brandIcon ?? "$",
pdfTemplate: pdfTemplate:
(settings?.pdfTemplate as "classic" | "minimal") ?? "classic", (settings?.pdfTemplate as "classic" | "minimal") ??
pdfAccentColor: settings?.pdfAccentColor ?? "#111827", fallbackAppearance.pdfTemplate,
pdfFooterText: settings?.pdfFooterText ?? "Professional Invoicing", pdfAccentColor:
pdfShowLogo: settings?.pdfShowLogo ?? true, settings?.pdfAccentColor ?? fallbackAppearance.pdfAccentColor,
pdfShowPageNumbers: settings?.pdfShowPageNumbers ?? true, pdfFooterText:
settings?.pdfFooterText ?? fallbackAppearance.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo ?? fallbackAppearance.pdfShowLogo,
pdfShowPageNumbers:
settings?.pdfShowPageNumbers ?? fallbackAppearance.pdfShowPageNumbers,
}; };
}), }),
@@ -252,30 +266,19 @@ export const settingsRouter = createTRPCRouter({
updateTheme: protectedProcedure updateTheme: protectedProcedure
.input( .input(
z.object({ z.object({
colorTheme: z colorTheme: colorThemeSchema.optional(),
.enum(["slate", "blue", "green", "rose", "orange", "custom"]) customColor: hslChannelsSchema.optional(),
.optional(), theme: colorModeSchema.optional(),
customColor: z.string().optional(), interfaceTheme: interfaceThemeSchema.optional(),
theme: z.enum(["light", "dark", "system"]).optional(), bodyFontPreference: fontPreferenceSchema.optional(),
interfaceTheme: z headingFontPreference: fontPreferenceSchema.optional(),
.enum(["beenvoice", "shadcn", "minimal", "editorial"]) radiusPreference: radiusPreferenceSchema.optional(),
.optional(), sidebarStyle: sidebarStyleSchema.optional(),
fontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
bodyFontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
headingFontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
radiusPreference: z.enum(["none", "sm", "md", "lg", "xl"]).optional(),
sidebarStyle: z.enum(["floating", "docked"]).optional(),
brandName: z.string().min(1).max(100).optional(), brandName: z.string().min(1).max(100).optional(),
brandTagline: z.string().min(1).max(255).optional(), brandTagline: z.string().min(1).max(255).optional(),
brandLogoText: z.string().min(1).max(100).optional(), brandLogoText: z.string().min(1).max(100).optional(),
brandIcon: z.string().min(1).max(20).optional(), brandIcon: z.string().min(1).max(20).optional(),
pdfTemplate: z.enum(["classic", "minimal"]).optional(), pdfTemplate: pdfTemplateSchema.optional(),
pdfAccentColor: z.string().min(4).max(50).optional(), pdfAccentColor: z.string().min(4).max(50).optional(),
pdfFooterText: z.string().min(1).max(120).optional(), pdfFooterText: z.string().min(1).max(120).optional(),
pdfShowLogo: z.boolean().optional(), pdfShowLogo: z.boolean().optional(),
@@ -288,15 +291,14 @@ export const settingsRouter = createTRPCRouter({
.insert(platformSettings) .insert(platformSettings)
.values({ .values({
id: "global", id: "global",
brandName: input.brandName ?? "beenvoice", brandName: input.brandName ?? fallbackAppearance.brandName,
brandTagline: brandTagline: input.brandTagline ?? fallbackAppearance.brandTagline,
input.brandTagline ?? brandLogoText:
"Simple and efficient invoicing for freelancers and small businesses", input.brandLogoText ?? fallbackAppearance.brandLogoText,
brandLogoText: input.brandLogoText ?? "beenvoice", brandIcon: input.brandIcon ?? fallbackAppearance.brandIcon,
brandIcon: input.brandIcon ?? "$", colorTheme: input.colorTheme ?? fallbackAppearance.colorTheme,
colorTheme: input.colorTheme ?? "slate",
customColor: input.customColor, customColor: input.customColor,
theme: input.theme ?? "system", theme: input.theme ?? fallbackAppearance.colorMode,
interfaceTheme: input.interfaceTheme ?? defaultInterfaceTheme, interfaceTheme: input.interfaceTheme ?? defaultInterfaceTheme,
bodyFontPreference: bodyFontPreference:
input.bodyFontPreference ?? defaultBodyFontPreference, input.bodyFontPreference ?? defaultBodyFontPreference,
@@ -304,11 +306,14 @@ export const settingsRouter = createTRPCRouter({
input.headingFontPreference ?? defaultHeadingFontPreference, input.headingFontPreference ?? defaultHeadingFontPreference,
radiusPreference: input.radiusPreference ?? defaultRadiusPreference, radiusPreference: input.radiusPreference ?? defaultRadiusPreference,
sidebarStyle: input.sidebarStyle ?? defaultSidebarStyle, sidebarStyle: input.sidebarStyle ?? defaultSidebarStyle,
pdfTemplate: input.pdfTemplate ?? "classic", pdfTemplate: input.pdfTemplate ?? fallbackAppearance.pdfTemplate,
pdfAccentColor: input.pdfAccentColor ?? "#111827", pdfAccentColor:
pdfFooterText: input.pdfFooterText ?? "Professional Invoicing", input.pdfAccentColor ?? fallbackAppearance.pdfAccentColor,
pdfShowLogo: input.pdfShowLogo ?? true, pdfFooterText:
pdfShowPageNumbers: input.pdfShowPageNumbers ?? true, input.pdfFooterText ?? fallbackAppearance.pdfFooterText,
pdfShowLogo: input.pdfShowLogo ?? fallbackAppearance.pdfShowLogo,
pdfShowPageNumbers:
input.pdfShowPageNumbers ?? fallbackAppearance.pdfShowPageNumbers,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: platformSettings.id, target: platformSettings.id,
@@ -424,14 +429,39 @@ export const settingsRouter = createTRPCRouter({
saltRounds, saltRounds,
); );
// Update the password await ctx.db.transaction(async (tx) => {
await ctx.db await tx
.update(users) .update(users)
.set({ .set({
password: hashedNewPassword, password: hashedNewPassword,
}) })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
const credentialAccount = await tx.query.accounts.findFirst({
where: and(
eq(accounts.userId, userId),
eq(accounts.providerId, "credential"),
),
});
if (credentialAccount) {
await tx
.update(accounts)
.set({
password: hashedNewPassword,
updatedAt: new Date(),
})
.where(eq(accounts.id, credentialAccount.id));
} else {
await tx.insert(accounts).values({
userId,
accountId: userId,
providerId: "credential",
password: hashedNewPassword,
});
}
});
return { success: true }; return { success: true };
}), }),
@@ -562,6 +592,7 @@ export const settingsRouter = createTRPCRouter({
totalAmount: invoice.totalAmount, totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate, taxRate: invoice.taxRate,
notes: invoice.notes ?? undefined, notes: invoice.notes ?? undefined,
emailMessage: invoice.emailMessage ?? undefined,
items: invoice.items, items: invoice.items,
})), })),
}; };
@@ -641,6 +672,7 @@ export const settingsRouter = createTRPCRouter({
totalAmount: invoiceData.totalAmount, totalAmount: invoiceData.totalAmount,
taxRate: invoiceData.taxRate, taxRate: invoiceData.taxRate,
notes: invoiceData.notes, notes: invoiceData.notes,
emailMessage: invoiceData.emailMessage,
createdById: userId, createdById: userId,
}) })
.returning({ id: invoices.id }); .returning({ id: invoices.id });

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