mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
Compare commits
3 Commits
bd3181fb9d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e46fdafb2 | |||
| ddc2b42672 | |||
| dbb739b060 |
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 ("Terms") govern your use of the
|
These Terms of Service ("Terms") govern your use of
|
||||||
beenvoice platform and services (the "Service") operated by
|
the beenvoice platform and services (the "Service")
|
||||||
beenvoice ("us", "we", or "our").
|
operated by beenvoice ("us", "we", or
|
||||||
|
"our").
|
||||||
</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
|
||||||
|
|||||||
@@ -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
|
||||||
name: `${firstName} ${lastName}`,
|
.insert(users)
|
||||||
email,
|
.values({
|
||||||
password: hashedPassword,
|
name: `${firstName} ${lastName}`,
|
||||||
|
email: normalizedEmail,
|
||||||
|
password: hashedPassword,
|
||||||
|
})
|
||||||
|
.returning({ id: users.id });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Failed to create user");
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(accounts).values({
|
||||||
|
userId: user.id,
|
||||||
|
accountId: user.id,
|
||||||
|
providerId: "credential",
|
||||||
|
password: hashedPassword,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 +47,40 @@ 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,
|
||||||
resetToken: null,
|
resetToken: null,
|
||||||
resetTokenExpiry: null,
|
resetTokenExpiry: null,
|
||||||
})
|
})
|
||||||
.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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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%)"
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export function StatusManager({
|
|||||||
|
|
||||||
{/* Overdue Warning */}
|
{/* Overdue Warning */}
|
||||||
{isOverdue && (
|
{isOverdue && (
|
||||||
<div className="bg-destructive/10 text-destructive flex items-center gap-2 p-3">
|
<div className="bg-destructive/10 text-destructive flex items-center gap-2 p-3">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{daysPastDue} day{daysPastDue !== 1 ? "s" : ""} overdue
|
{daysPastDue} day{daysPastDue !== 1 ? "s" : ""} overdue
|
||||||
@@ -325,7 +325,7 @@ export function StatusManager({
|
|||||||
|
|
||||||
{/* No Email Warning */}
|
{/* No Email Warning */}
|
||||||
{!clientEmail && effectiveStatus !== "paid" && (
|
{!clientEmail && effectiveStatus !== "paid" && (
|
||||||
<div className="bg-muted text-muted-foreground p-3">
|
<div className="bg-muted text-muted-foreground p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)} · {item.hours}h @ {formatCurrency(item.rate)}/hr
|
{formatDate(item.date)} · {item.hours}h @{" "}
|
||||||
|
{formatCurrency(item.rate)}/hr
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,17 +109,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||||
const total = subtotal + taxAmount;
|
const total = subtotal + taxAmount;
|
||||||
|
const 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 (
|
||||||
|
|||||||
@@ -292,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>
|
||||||
@@ -302,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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function FormatInstructions() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="bg-muted/50 p-4">
|
<div className="bg-muted/50 p-4">
|
||||||
<p className="text-muted-foreground font-mono text-sm">
|
<p className="text-muted-foreground font-mono text-sm">
|
||||||
DATE,DESCRIPTION,HOURS,RATE,AMOUNT
|
DATE,DESCRIPTION,HOURS,RATE,AMOUNT
|
||||||
</p>
|
</p>
|
||||||
@@ -85,7 +85,7 @@ function FormatInstructions() {
|
|||||||
for importing time entries.
|
for importing time entries.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-primary/10 p-4">
|
<div className="bg-primary/10 p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Info className="text-primary mt-0.5 h-5 w-5" />
|
<Info className="text-primary mt-0.5 h-5 w-5" />
|
||||||
<div>
|
<div>
|
||||||
@@ -100,7 +100,7 @@ function FormatInstructions() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold">Sample Row:</h4>
|
<h4 className="text-sm font-semibold">Sample Row:</h4>
|
||||||
<div className="bg-muted/50 p-3">
|
<div className="bg-muted/50 p-3">
|
||||||
<p className="text-muted font-mono text-xs break-all">
|
<p className="text-muted font-mono text-xs break-all">
|
||||||
1/15/24,"Web development work",8,75.00,600.00
|
1/15/24,"Web development work",8,75.00,600.00
|
||||||
</p>
|
</p>
|
||||||
@@ -109,7 +109,7 @@ function FormatInstructions() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold">Sample Filename:</h4>
|
<h4 className="text-sm font-semibold">Sample Filename:</h4>
|
||||||
<div className="bg-muted/50 p-3">
|
<div className="bg-muted/50 p-3">
|
||||||
<p className="text-muted font-mono text-xs">2024-01-15.csv</p>
|
<p className="text-muted font-mono text-xs">2024-01-15.csv</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,7 +168,7 @@ function FileFormatHelp() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<div className="bg-accent mx-auto w-fit p-3">
|
<div className="bg-accent mx-auto w-fit p-3">
|
||||||
<FileSpreadsheet className="text-foreground-foreground h-6 w-6" />
|
<FileSpreadsheet className="text-foreground-foreground h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-semibold">CSV Files</h4>
|
<h4 className="font-semibold">CSV Files</h4>
|
||||||
@@ -178,7 +178,7 @@ function FileFormatHelp() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<div className="bg-primary/10 mx-auto w-fit p-3">
|
<div className="bg-primary/10 mx-auto w-fit p-3">
|
||||||
<Upload className="text-primary h-6 w-6" />
|
<Upload className="text-primary h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-semibold">Max Size</h4>
|
<h4 className="font-semibold">Max Size</h4>
|
||||||
@@ -187,7 +187,7 @@ function FileFormatHelp() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<div className="bg-secondary mx-auto w-fit p-3">
|
<div className="bg-secondary mx-auto w-fit p-3">
|
||||||
<CheckCircle className="text-muted-foreground-foreground h-6 w-6" />
|
<CheckCircle className="text-muted-foreground-foreground h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-semibold">Validation</h4>
|
<h4 className="font-semibold">Validation</h4>
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
type: "notes",
|
||||||
|
content: "",
|
||||||
|
isDefault: false,
|
||||||
|
};
|
||||||
|
|
||||||
export default function TemplatesPage() {
|
type InvoiceTemplate = RouterOutputs["invoiceTemplates"]["getAll"][number];
|
||||||
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();
|
interface TemplateListProps {
|
||||||
const { data: templates = [], isLoading } = api.invoiceTemplates.getAll.useQuery();
|
items: InvoiceTemplate[];
|
||||||
|
type: "notes" | "terms";
|
||||||
|
isLoading: boolean;
|
||||||
|
onCreate: (type: "notes" | "terms") => void;
|
||||||
|
onEdit: (template: InvoiceTemplate) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const create = api.invoiceTemplates.create.useMutation({
|
function TemplateList({
|
||||||
onSuccess: () => { toast.success("Template created"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setForm(defaultForm); },
|
items,
|
||||||
onError: (e) => toast.error(e.message),
|
type,
|
||||||
});
|
isLoading,
|
||||||
const update = api.invoiceTemplates.update.useMutation({
|
onCreate,
|
||||||
onSuccess: () => { toast.success("Template updated"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); },
|
onEdit,
|
||||||
onError: (e) => toast.error(e.message),
|
onDelete,
|
||||||
});
|
}: TemplateListProps) {
|
||||||
const del = api.invoiceTemplates.delete.useMutation({
|
return (
|
||||||
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");
|
|
||||||
const termsTemplates = templates.filter((t) => t.type === "terms");
|
|
||||||
|
|
||||||
const TemplateList = ({ items, type }: { items: typeof templates; type: "notes" | "terms" }) => (
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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,10 +193,11 @@ 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 ${
|
||||||
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
action.featured
|
||||||
: "border-border bg-background hover:bg-muted/50"
|
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
||||||
}`}
|
: "border-border bg-background hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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"
|
value={hslChannelsToHex(customColorValue)}
|
||||||
style={{
|
onBlur={() => undefined}
|
||||||
backgroundColor: `hsl(${customColorValue})`,
|
onChange={(value) => {
|
||||||
}}
|
if (isFullHexColor(value)) {
|
||||||
/>
|
updateAppearanceDebounced({
|
||||||
Pick color
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={hslChannelsToHex(customColorValue)}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateAppearance({
|
|
||||||
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,119 +1144,6 @@ export function SettingsContent() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-4 border-t pt-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium">PDF</h3>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Controls the generated invoice PDF used for downloads and
|
|
||||||
email attachments.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
PDF Template
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={pdfTemplate}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateAppearance({
|
|
||||||
pdfTemplate: value as typeof pdfTemplate,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="classic">Classic</SelectItem>
|
|
||||||
<SelectItem value="minimal">Minimal</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-muted-foreground text-xs leading-snug">
|
|
||||||
Minimal removes shaded table fills for a cleaner document.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>PDF Accent</Label>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
|
||||||
<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}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateAppearance({
|
|
||||||
pdfAccentColor: event.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="sr-only"
|
|
||||||
aria-label="Pick PDF accent color"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={pdfAccentColor}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateAppearance({
|
|
||||||
pdfAccentColor: event.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="#111827"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Footer Text</Label>
|
|
||||||
<Input
|
|
||||||
value={pdfFooterText}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateAppearance({ pdfFooterText: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Show Logo</Label>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Include the beenvoice logo in the PDF footer.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={pdfShowLogo}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
updateAppearance({ pdfShowLogo: Boolean(checked) })
|
|
||||||
}
|
|
||||||
aria-label="Toggle PDF logo"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Page Numbers</Label>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Show page count in the PDF footer.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={pdfShowPageNumbers}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
updateAppearance({
|
|
||||||
pdfShowPageNumbers: Boolean(checked),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
aria-label="Toggle PDF page numbers"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{appearanceUpdating && (
|
{appearanceUpdating && (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Saving appearance...
|
Saving appearance...
|
||||||
@@ -1260,6 +1153,130 @@ export function SettingsContent() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</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">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
PDF Template
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={pdfTemplate}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateAppearance({
|
||||||
|
pdfTemplate: value as typeof pdfTemplate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="classic">Classic</SelectItem>
|
||||||
|
<SelectItem value="minimal">Minimal</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs leading-snug">
|
||||||
|
Minimal removes shaded table fills for a cleaner
|
||||||
|
document.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<InputColor
|
||||||
|
label="PDF Accent"
|
||||||
|
value={pdfAccentColor}
|
||||||
|
onBlur={() => undefined}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (isFullHexColor(value)) {
|
||||||
|
updateAppearance({
|
||||||
|
pdfAccentColor: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mt-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Footer Text</Label>
|
||||||
|
<Input
|
||||||
|
value={pdfFooterText}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateAppearanceDebounced({
|
||||||
|
pdfFooterText: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<Label>Show Logo</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Include the beenvoice logo in the PDF footer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={pdfShowLogo}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateAppearance({ pdfShowLogo: Boolean(checked) })
|
||||||
|
}
|
||||||
|
aria-label="Toggle PDF logo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-4 border p-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Page Numbers</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Show page count in the PDF footer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={pdfShowPageNumbers}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateAppearance({
|
||||||
|
pdfShowPageNumbers: Boolean(checked),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label="Toggle PDF page numbers"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PdfPreviewFrame
|
||||||
|
businessName={brandName}
|
||||||
|
settings={{
|
||||||
|
pdfTemplate,
|
||||||
|
pdfAccentColor,
|
||||||
|
pdfFooterText,
|
||||||
|
pdfShowLogo,
|
||||||
|
pdfShowPageNumbers,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Accessibility & Animation */}
|
{/* Accessibility & Animation */}
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
<HydrateClient>
|
||||||
<CardContent className="p-6">
|
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
||||||
<HydrateClient>
|
<SettingsContent />
|
||||||
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
</Suspense>
|
||||||
<SettingsContent />
|
</HydrateClient>
|
||||||
</Suspense>
|
|
||||||
</HydrateClient>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-15
@@ -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;
|
||||||
|
|||||||
+84
-254
@@ -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>
|
<Link href="/auth/signin">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{allowRegistration && (
|
||||||
|
<Link href="/auth/register">
|
||||||
|
<Button size="sm">Create account</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
{/* Navigation */}
|
<section className="grid flex-1 items-center gap-10 py-14 md:grid-cols-[1fr_320px] md:py-20">
|
||||||
<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="max-w-2xl space-y-7">
|
||||||
<div className="mx-auto px-6">
|
<div className="space-y-4">
|
||||||
<div className="flex h-16 items-center justify-between">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
<Logo />
|
Personal invoicing
|
||||||
<div className="hidden items-center space-x-8 md:flex">
|
</p>
|
||||||
<a
|
<h1 className="font-heading text-4xl leading-tight font-bold tracking-normal sm:text-5xl">
|
||||||
href="#features"
|
{brand.name} is a place to make and track invoices.
|
||||||
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
|
</h1>
|
||||||
>
|
<p className="text-muted-foreground max-w-xl text-base leading-7 sm:text-lg">
|
||||||
Features
|
Built for one person managing real clients, real work, and the
|
||||||
</a>
|
small admin loop around getting paid.
|
||||||
<a
|
</p>
|
||||||
href="#pricing"
|
|
||||||
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Pricing
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
<Link href="/auth/signin">
|
<Link href="/auth/signin">
|
||||||
<Button
|
<Button size="lg" className="h-11 px-5">
|
||||||
variant="ghost"
|
Open workspace
|
||||||
size="sm"
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/auth/register">
|
{allowRegistration && (
|
||||||
<Button size="sm" variant="default" className="rounded-xl px-6">
|
<Link href="/auth/register">
|
||||||
Get Started
|
<Button variant="outline" size="lg" className="h-11 px-5">
|
||||||
</Button>
|
Create account
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<section className="relative pt-48 pb-32">
|
|
||||||
<div className="container mx-auto px-4 text-center">
|
|
||||||
<div className="mx-auto max-w-4xl">
|
|
||||||
<Badge className="bg-primary/10 text-primary border-primary/20 mb-8 rounded-full border px-4 py-1 text-sm">
|
|
||||||
<Zap className="mr-2 h-3.5 w-3.5" />
|
|
||||||
Completely Free for Everyone
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mx-auto mb-12 max-w-2xl font-sans text-xl leading-relaxed">
|
|
||||||
{brand.tagline}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
|
|
||||||
<Link href="/auth/register">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
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>
|
|
||||||
</Link>
|
|
||||||
<a href="#features">
|
|
||||||
<Button
|
|
||||||
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 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="flex items-center gap-2">
|
|
||||||
<Check className="text-primary h-4 w-4" />
|
|
||||||
<span>No credit card required</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Check className="text-primary h-4 w-4" />
|
|
||||||
<span>Setup in 2 minutes</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Check className="text-primary h-4 w-4" />
|
|
||||||
<span>Free forever</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Features Section */}
|
|
||||||
<section id="features" className="relative py-24">
|
|
||||||
<div className="relative z-10 container mx-auto px-4">
|
|
||||||
<div className="mb-20 text-center">
|
|
||||||
<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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer */}
|
<div className="border-border bg-card text-card-foreground rounded-xl border p-5 shadow-sm">
|
||||||
<footer className="border-border/40 bg-background/50 mt-12 border-t py-12 backdrop-blur-sm">
|
<div className="space-y-5">
|
||||||
<div className="container mx-auto flex flex-col items-center justify-between gap-6 px-6 md:flex-row">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="bg-primary/10 text-primary rounded-md p-2">
|
||||||
<Logo size="sm" />
|
<UserRound className="h-4 w-4" />
|
||||||
<span className="text-muted-foreground text-sm">
|
</div>
|
||||||
© 2024 beenvoice
|
<div>
|
||||||
</span>
|
<h2 className="text-sm font-semibold">Clients</h2>
|
||||||
|
<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-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 className="text-muted-foreground flex gap-8 text-sm">
|
</section>
|
||||||
<a href="#" className="hover:text-foreground transition-colors">
|
|
||||||
|
<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">
|
||||||
|
<span>© 2026 {brand.name}</span>
|
||||||
|
<div className="flex gap-5">
|
||||||
|
<Link href="/privacy" className="hover:text-foreground">
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,20 @@ import Script from "next/script";
|
|||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
|
|
||||||
export function UmamiScript() {
|
export function UmamiScript() {
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
|
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Script
|
<Script
|
||||||
defer
|
defer
|
||||||
src={env.NEXT_PUBLIC_UMAMI_SCRIPT_URL}
|
src={env.NEXT_PUBLIC_UMAMI_SCRIPT_URL}
|
||||||
data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ export function CSVImportPage() {
|
|||||||
applyGlobalClient(newClientId);
|
applyGlobalClient(newClientId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-12 w-full border px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-12 w-full border px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={loadingClients}
|
disabled={loadingClients}
|
||||||
>
|
>
|
||||||
<option value="">No default client (select individually)</option>
|
<option value="">No default client (select individually)</option>
|
||||||
@@ -506,7 +506,7 @@ export function CSVImportPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="bg-primary/10 grid grid-cols-2 gap-4 p-4 md:grid-cols-4">
|
<div className="bg-primary/10 grid grid-cols-2 gap-4 p-4 md:grid-cols-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-primary text-2xl font-bold">
|
<div className="text-primary text-2xl font-bold">
|
||||||
{totalFiles}
|
{totalFiles}
|
||||||
@@ -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" />
|
||||||
@@ -619,7 +616,7 @@ export function CSVImportPage() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateFileData(index, { clientId: e.target.value })
|
updateFileData(index, { clientId: e.target.value })
|
||||||
}
|
}
|
||||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={loadingClients}
|
disabled={loadingClients}
|
||||||
>
|
>
|
||||||
<option value="">Select Client</option>
|
<option value="">Select Client</option>
|
||||||
@@ -662,7 +659,7 @@ export function CSVImportPage() {
|
|||||||
|
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
{fileData.errors.length > 0 && (
|
{fileData.errors.length > 0 && (
|
||||||
<div className="border-destructive/20 bg-destructive/10 mt-4 border p-3">
|
<div className="border-destructive/20 bg-destructive/10 mt-4 border p-3">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<AlertCircle className="text-destructive h-4 w-4" />
|
<AlertCircle className="text-destructive h-4 w-4" />
|
||||||
<span className="text-destructive text-sm font-medium">
|
<span className="text-destructive text-sm font-medium">
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -383,9 +376,9 @@ export function DataTable<TData, TValue>({
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -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,26 +442,28 @@ 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.pageSize +
|
table.getState().pagination.pageIndex *
|
||||||
1
|
table.getState().pagination.pageSize +
|
||||||
} to ${Math.min(
|
1
|
||||||
(table.getState().pagination.pageIndex + 1) *
|
} to ${Math.min(
|
||||||
table.getState().pagination.pageSize,
|
(table.getState().pagination.pageIndex + 1) *
|
||||||
table.getFilteredRowModel().rows.length,
|
table.getState().pagination.pageSize,
|
||||||
)} of ${table.getFilteredRowModel().rows.length} entries`}
|
table.getFilteredRowModel().rows.length,
|
||||||
|
)} of ${table.getFilteredRowModel().rows.length} entries`}
|
||||||
</p>
|
</p>
|
||||||
<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.pageSize +
|
table.getState().pagination.pageIndex *
|
||||||
1
|
table.getState().pagination.pageSize +
|
||||||
}-${Math.min(
|
1
|
||||||
(table.getState().pagination.pageIndex + 1) *
|
}-${Math.min(
|
||||||
table.getState().pagination.pageSize,
|
(table.getState().pagination.pageIndex + 1) *
|
||||||
table.getFilteredRowModel().rows.length,
|
table.getState().pagination.pageSize,
|
||||||
)} of ${table.getFilteredRowModel().rows.length}`}
|
table.getFilteredRowModel().rows.length,
|
||||||
|
)} of ${table.getFilteredRowModel().rows.length}`}
|
||||||
</p>
|
</p>
|
||||||
<Select
|
<Select
|
||||||
value={table.getState().pagination.pageSize.toString()}
|
value={table.getState().pagination.pageSize.toString()}
|
||||||
|
|||||||
@@ -87,8 +87,9 @@ 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 */}
|
||||||
<div className="hidden items-center gap-3 p-4 md:grid md:grid-cols-12">
|
<div className="hidden items-center gap-3 p-4 md:grid md:grid-cols-12">
|
||||||
@@ -153,7 +154,7 @@ function SortableItem({
|
|||||||
|
|
||||||
{/* Amount */}
|
{/* Amount */}
|
||||||
<div className="col-span-1">
|
<div className="col-span-1">
|
||||||
<div className="bg-muted/30 text-primary flex h-9 items-center border px-3 font-medium">
|
<div className="bg-muted/30 text-primary flex h-9 items-center border px-3 font-medium">
|
||||||
${item.amount.toFixed(2)}
|
${item.amount.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,7 +266,7 @@ function SortableItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Amount */}
|
{/* Amount */}
|
||||||
<div className="bg-muted/20 border p-3">
|
<div className="bg-muted/20 border p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground text-sm">Total Amount:</span>
|
<span className="text-muted-foreground text-sm">Total Amount:</span>
|
||||||
<span className="text-primary font-mono text-lg font-bold">
|
<span className="text-primary font-mono text-lg font-bold">
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function StatsCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<div className={cn(" p-3", styles.background)}>
|
<div className={cn("p-3", styles.background)}>
|
||||||
<Icon className={cn("h-6 w-6", styles.icon)} />
|
<Icon className={cn("h-6 w-6", styles.icon)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 ?? "",
|
||||||
|
|||||||
@@ -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 ?? "",
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function FilePreview({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between border p-3",
|
"flex items-center justify-between border p-3",
|
||||||
getStatusColor(),
|
getStatusColor(),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -152,7 +152,7 @@ export function FileUpload({
|
|||||||
<div
|
<div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer border-2 border-dashed p-8 text-center transition-colors",
|
"cursor-pointer border-2 border-dashed p-8 text-center transition-colors",
|
||||||
"hover:border-primary/40 hover:bg-primary/10",
|
"hover:border-primary/40 hover:bg-primary/10",
|
||||||
isDragActive && "border-primary/40 bg-primary/10",
|
isDragActive && "border-primary/40 bg-primary/10",
|
||||||
isDragReject && "border-destructive/40 bg-destructive/10",
|
isDragReject && "border-destructive/40 bg-destructive/10",
|
||||||
@@ -164,7 +164,7 @@ export function FileUpload({
|
|||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
" p-3 transition-colors",
|
"p-3 transition-colors",
|
||||||
isDragActive ? "bg-primary/10" : "bg-muted",
|
isDragActive ? "bg-primary/10" : "bg-muted",
|
||||||
isDragReject && "bg-destructive/10",
|
isDragReject && "bg-destructive/10",
|
||||||
)}
|
)}
|
||||||
@@ -222,7 +222,7 @@ export function FileUpload({
|
|||||||
|
|
||||||
{/* Error Summary */}
|
{/* Error Summary */}
|
||||||
{Object.keys(errors).length > 0 && (
|
{Object.keys(errors).length > 0 && (
|
||||||
<div className="border-destructive/20 bg-destructive/10 border p-3">
|
<div className="border-destructive/20 bg-destructive/10 border p-3">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<AlertCircle className="text-destructive h-4 w-4" />
|
<AlertCircle className="text-destructive h-4 w-4" />
|
||||||
<span className="text-destructive text-sm font-medium">
|
<span className="text-destructive text-sm font-medium">
|
||||||
|
|||||||
@@ -1,398 +1,520 @@
|
|||||||
"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,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetFooter,
|
SheetFooter,
|
||||||
} from "~/components/ui/sheet";
|
} from "~/components/ui/sheet";
|
||||||
import { Button } from "~/components/ui/button";
|
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;
|
||||||
description: string;
|
description: string;
|
||||||
hours: number;
|
hours: number;
|
||||||
rate: number;
|
rate: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InvoiceCalendarViewProps {
|
interface InvoiceCalendarViewProps {
|
||||||
items: InvoiceItem[];
|
items: InvoiceItem[];
|
||||||
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;
|
||||||
className?: string;
|
className?: string;
|
||||||
defaultHourlyRate: number | null;
|
defaultHourlyRate: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvoiceCalendarView({
|
export function InvoiceCalendarView({
|
||||||
items,
|
items,
|
||||||
onUpdateItem,
|
onUpdateItem,
|
||||||
onAddItem,
|
onAddItem,
|
||||||
onRemoveItem,
|
onRemoveItem,
|
||||||
className,
|
className,
|
||||||
defaultHourlyRate: _defaultHourlyRate,
|
defaultHourlyRate: _defaultHourlyRate,
|
||||||
}: InvoiceCalendarViewProps) {
|
}: InvoiceCalendarViewProps) {
|
||||||
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
|
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
|
||||||
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
|
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
|
||||||
const [view, setView] = React.useState<"month" | "week">("month");
|
const [view, setView] = React.useState<"month" | "week">("month");
|
||||||
const [sheetOpen, setSheetOpen] = React.useState(false);
|
const [sheetOpen, setSheetOpen] = React.useState(false);
|
||||||
// Derived state for selected date items - solves cursor jumping
|
// Derived state for selected date items - solves cursor jumping
|
||||||
const selectedDateItems = React.useMemo(() => {
|
const selectedDateItems = React.useMemo(() => {
|
||||||
if (!date) return [];
|
if (!date) return [];
|
||||||
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, date);
|
return isSameDay(itemDate, date);
|
||||||
});
|
});
|
||||||
}, [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(
|
||||||
return items
|
(targetDate: Date) => {
|
||||||
.map((item, index) => ({ item, index }))
|
return items
|
||||||
.filter((wrapper) => {
|
.map((item, index) => ({ item, index }))
|
||||||
const itemDate = new Date(wrapper.item.date);
|
.filter((wrapper) => {
|
||||||
return isSameDay(itemDate, targetDate);
|
const itemDate = new Date(wrapper.item.date);
|
||||||
});
|
return isSameDay(itemDate, targetDate);
|
||||||
}, [items]);
|
});
|
||||||
|
},
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSelectDate = (newDate: Date | undefined) => {
|
const handleSelectDate = (newDate: Date | undefined) => {
|
||||||
if (!newDate) return;
|
if (!newDate) return;
|
||||||
setDate(newDate);
|
setDate(newDate);
|
||||||
setSheetOpen(true);
|
setSheetOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddNewItem = () => {
|
const handleAddNewItem = () => {
|
||||||
if (date) {
|
if (date) {
|
||||||
onAddItem(date);
|
onAddItem(date);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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);
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setDate(undefined);
|
setDate(undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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
|
||||||
<ChevronLeft className="h-4 w-4" />
|
variant="outline"
|
||||||
</Button>
|
size="icon"
|
||||||
<span className="text-sm font-medium w-36 text-center">
|
onClick={() => setViewDate((d) => subWeeks(d, 1))}
|
||||||
{`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`}
|
className="h-8 w-8 rounded-lg"
|
||||||
</span>
|
>
|
||||||
<Button variant="outline" size="icon" onClick={() => setViewDate(d => addWeeks(d, 1))} className="h-8 w-8 rounded-lg">
|
<ChevronLeft className="h-4 w-4" />
|
||||||
<ChevronRight className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
<span className="w-36 text-center text-sm font-medium">
|
||||||
</>
|
{`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`}
|
||||||
) : (
|
</span>
|
||||||
<>
|
<Button
|
||||||
<Button variant="outline" size="icon" onClick={() => setViewDate(d => subMonths(d, 1))} className="h-8 w-8 rounded-lg">
|
variant="outline"
|
||||||
<ChevronLeft className="h-4 w-4" />
|
size="icon"
|
||||||
</Button>
|
onClick={() => setViewDate((d) => addWeeks(d, 1))}
|
||||||
<span className="text-sm font-medium w-36 text-center">
|
className="h-8 w-8 rounded-lg"
|
||||||
{format(viewDate, "MMMM yyyy")}
|
>
|
||||||
</span>
|
<ChevronRight className="h-4 w-4" />
|
||||||
<Button variant="outline" size="icon" onClick={() => setViewDate(d => addMonths(d, 1))} className="h-8 w-8 rounded-lg">
|
</Button>
|
||||||
<ChevronRight className="h-4 w-4" />
|
</>
|
||||||
</Button>
|
) : (
|
||||||
</>
|
<>
|
||||||
)}
|
<Button
|
||||||
</div>
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
<div className="flex items-center space-x-2 ml-auto">
|
onClick={() => setViewDate((d) => subMonths(d, 1))}
|
||||||
{/* View Switcher */}
|
className="h-8 w-8 rounded-lg"
|
||||||
<div className="bg-muted p-1 rounded-lg flex text-sm">
|
>
|
||||||
<button
|
<ChevronLeft className="h-4 w-4" />
|
||||||
type="button"
|
</Button>
|
||||||
onClick={() => setView("month")}
|
<span className="w-36 text-center text-sm font-medium">
|
||||||
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")}
|
{format(viewDate, "MMMM yyyy")}
|
||||||
>
|
</span>
|
||||||
Month
|
<Button
|
||||||
</button>
|
variant="outline"
|
||||||
<button
|
size="icon"
|
||||||
type="button"
|
onClick={() => setViewDate((d) => addMonths(d, 1))}
|
||||||
onClick={() => setView("week")}
|
className="h-8 w-8 rounded-lg"
|
||||||
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")}
|
>
|
||||||
>
|
<ChevronRight className="h-4 w-4" />
|
||||||
Week
|
</Button>
|
||||||
</button>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 w-full overflow-hidden">
|
|
||||||
{view === "month" ? (
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={date}
|
|
||||||
onSelect={handleSelectDate}
|
|
||||||
month={viewDate}
|
|
||||||
onMonthChange={setViewDate}
|
|
||||||
className="rounded-md border-0 w-full p-0"
|
|
||||||
classNames={{
|
|
||||||
root: "w-full p-0",
|
|
||||||
months: "flex flex-col w-full",
|
|
||||||
month: "flex flex-col w-full space-y-4",
|
|
||||||
|
|
||||||
// Grid - Revert to Flex but Enforce 1/7th Width
|
|
||||||
// table: "w-full border-collapse", // No table-fixed
|
|
||||||
head_row: "flex w-full",
|
|
||||||
row: "flex w-full mt-2",
|
|
||||||
|
|
||||||
// Cells & Headers: Explicit width 14.28%
|
|
||||||
// Use calc(100%/7) via tailwind arbitrary or just flex bases.
|
|
||||||
// Better: w-[14.28%] flex-none (approx 1/7)
|
|
||||||
weekdays: "flex w-full border-b",
|
|
||||||
weekday: "w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
|
|
||||||
|
|
||||||
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",
|
|
||||||
|
|
||||||
// Hide internal navigation & caption entirely
|
|
||||||
nav: "hidden",
|
|
||||||
caption: "hidden",
|
|
||||||
|
|
||||||
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"
|
|
||||||
),
|
|
||||||
day_selected: "bg-primary/5 text-primary",
|
|
||||||
day_today: "bg-accent/20",
|
|
||||||
day_outside: "text-muted-foreground opacity-30",
|
|
||||||
}}
|
|
||||||
formatters={{
|
|
||||||
formatMonthCaption: () => "", // Clear default caption text to prevent duplication
|
|
||||||
}}
|
|
||||||
components={{
|
|
||||||
DayButton: (props) => {
|
|
||||||
const { day, modifiers, className, ...buttonProps } = props;
|
|
||||||
const DayDate = day.date;
|
|
||||||
const dayItems = getItemsForDate(DayDate);
|
|
||||||
// const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0); // Unused now
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
{...buttonProps}
|
|
||||||
type="button"
|
|
||||||
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",
|
|
||||||
// Selected State: Filled Box, No Outline
|
|
||||||
modifiers.selected && "bg-primary text-primary-foreground hover:bg-primary/90 shadow-md transform scale-[0.98]",
|
|
||||||
modifiers.today && !modifiers.selected && "bg-accent/40 rounded-xl",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium z-10">{DayDate.getDate()}</span>
|
|
||||||
{dayItems.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-1 w-full mt-1 overflow-hidden h-full justify-end pb-1">
|
|
||||||
<div className="flex flex-col gap-1 w-full mt-1">
|
|
||||||
{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")} />
|
|
||||||
))}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex gap-3 overflow-x-auto p-4 pb-6 w-full">
|
|
||||||
{weekDays.map((day) => {
|
|
||||||
const isSelected = date && isSameDay(day, date);
|
|
||||||
const isToday = isSameDay(day, new Date());
|
|
||||||
const dayItems = getItemsForDate(day);
|
|
||||||
const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={day.toString()}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSelectDate(day)}
|
|
||||||
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",
|
|
||||||
isSelected ? "ring-2 ring-primary ring-offset-2 bg-primary/5" : "bg-background/40",
|
|
||||||
isToday && !isSelected ? "bg-accent/40" : ""
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center mb-4 pb-4 border-b w-full">
|
|
||||||
<span className="text-xs font-bold text-muted-foreground uppercase">{format(day, "EEE")}</span>
|
|
||||||
<span className="text-2xl font-light">{format(day, "d")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 space-y-2 w-full overflow-hidden">
|
|
||||||
{dayItems.length > 0 ? (
|
|
||||||
dayItems.map(({ item }, i) => (
|
|
||||||
<div key={i} className="bg-background rounded-xl p-2 text-xs shadow-sm border">
|
|
||||||
<div className="font-medium line-clamp-2 text-wrap break-words">{item.description || "No description"}</div>
|
|
||||||
<div className="text-muted-foreground whitespace-nowrap">{item.hours}h</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="h-full flex items-center justify-center text-muted-foreground/20">
|
|
||||||
<Plus className="w-8 h-8" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dayItems.length > 0 && (
|
|
||||||
<div className="pt-2 mt-auto text-center w-full">
|
|
||||||
<span className="text-sm font-semibold">{totalHours}h Total</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sheet for Day Details */}
|
|
||||||
<Sheet
|
|
||||||
open={sheetOpen}
|
|
||||||
onOpenChange={handleCloseSheet}
|
|
||||||
>
|
|
||||||
<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="p-6 border-b">
|
|
||||||
<SheetTitle className="flex items-center gap-3 text-2xl flex-wrap">
|
|
||||||
<div className="bg-primary/10 p-2.5 rounded-full flex-shrink-0">
|
|
||||||
<CalendarIcon className="w-6 h-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<span className="break-words text-left">{date ? format(date, "EEEE, MMMM do") : "Details"}</span>
|
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{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-background p-4 rounded-full shadow-sm">
|
|
||||||
<Clock className="w-8 h-8 text-muted-foreground/50" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="font-semibold text-lg text-foreground">No hours logged</p>
|
|
||||||
<p className="text-sm text-muted-foreground/80 max-w-[200px]">There are no time entries recorded for this day yet.</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Log Time
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{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 className="space-y-3 p-4">
|
|
||||||
{/* Description */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-muted-foreground text-xs">Description</Label>
|
|
||||||
<Input
|
|
||||||
value={item.description}
|
|
||||||
onChange={(e) => onUpdateItem(index, "description", e.target.value)}
|
|
||||||
placeholder="Describe the work performed..."
|
|
||||||
className="pl-3 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hours and Rate in a row */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-muted-foreground text-xs">Hours</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={item.hours}
|
|
||||||
onChange={v => onUpdateItem(index, "hours", v)}
|
|
||||||
step={0.25}
|
|
||||||
min={0}
|
|
||||||
width="full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-muted-foreground text-xs">Rate</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={item.rate}
|
|
||||||
onChange={v => onUpdateItem(index, "rate", v)}
|
|
||||||
prefix="$"
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
width="full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom section with controls, item name, and total */}
|
|
||||||
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onRemoveItem(index)}
|
|
||||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 px-3 text-center">
|
|
||||||
<span className="text-muted-foreground block text-sm font-medium">
|
|
||||||
Item #{index + 1}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end">
|
|
||||||
<span className="text-muted-foreground text-xs">Total</span>
|
|
||||||
<span className="text-primary text-lg font-bold">
|
|
||||||
${(item.hours * item.rate).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</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">
|
|
||||||
<div className="bg-muted group-hover:bg-primary/10 p-1 rounded-md transition-colors">
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<span>Add Another Entry</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SheetFooter className="p-6 border-t bg-muted/10 mt-auto">
|
|
||||||
<Button className="w-full sm:w-full rounded-xl h-12 text-base shadow-md" size="lg" onClick={() => handleCloseSheet(false)}>Done</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
|
<div className="ml-auto flex items-center space-x-2">
|
||||||
|
{/* View Switcher */}
|
||||||
|
<div className="bg-muted flex rounded-lg p-1 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView("month")}
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView("week")}
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex-1 overflow-hidden">
|
||||||
|
{view === "month" ? (
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={date}
|
||||||
|
onSelect={handleSelectDate}
|
||||||
|
month={viewDate}
|
||||||
|
onMonthChange={setViewDate}
|
||||||
|
className="w-full rounded-md border-0 p-0"
|
||||||
|
classNames={{
|
||||||
|
root: "w-full p-0",
|
||||||
|
months: "flex flex-col w-full",
|
||||||
|
month: "flex flex-col w-full space-y-4",
|
||||||
|
|
||||||
|
// Grid - Revert to Flex but Enforce 1/7th Width
|
||||||
|
// table: "w-full border-collapse", // No table-fixed
|
||||||
|
head_row: "flex w-full",
|
||||||
|
row: "flex w-full mt-2",
|
||||||
|
|
||||||
|
// Cells & Headers: Explicit width 14.28%
|
||||||
|
// Use calc(100%/7) via tailwind arbitrary or just flex bases.
|
||||||
|
// Better: w-[14.28%] flex-none (approx 1/7)
|
||||||
|
weekdays: "flex w-full border-b",
|
||||||
|
weekday:
|
||||||
|
"w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
|
||||||
|
|
||||||
|
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",
|
||||||
|
|
||||||
|
// Hide internal navigation & caption entirely
|
||||||
|
nav: "hidden",
|
||||||
|
caption: "hidden",
|
||||||
|
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
day_selected: "bg-primary/5 text-primary",
|
||||||
|
day_today: "bg-accent/20",
|
||||||
|
day_outside: "text-muted-foreground opacity-30",
|
||||||
|
}}
|
||||||
|
formatters={{
|
||||||
|
formatMonthCaption: () => "", // Clear default caption text to prevent duplication
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
DayButton: (props) => {
|
||||||
|
const { day, modifiers, className, ...buttonProps } = props;
|
||||||
|
const DayDate = day.date;
|
||||||
|
const dayItems = getItemsForDate(DayDate);
|
||||||
|
// const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0); // Unused now
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...buttonProps}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"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
|
||||||
|
modifiers.selected &&
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary/90 scale-[0.98] transform shadow-md",
|
||||||
|
modifiers.today &&
|
||||||
|
!modifiers.selected &&
|
||||||
|
"bg-accent/40 rounded-xl",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="z-10 text-sm font-medium">
|
||||||
|
{DayDate.getDate()}
|
||||||
|
</span>
|
||||||
|
{dayItems.length > 0 && (
|
||||||
|
<div className="mt-1 flex h-full w-full flex-col justify-end gap-1 overflow-hidden pb-1">
|
||||||
|
<div className="mt-1 flex w-full flex-col gap-1">
|
||||||
|
{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",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full gap-3 overflow-x-auto p-4 pb-6">
|
||||||
|
{weekDays.map((day) => {
|
||||||
|
const isSelected = date && isSameDay(day, date);
|
||||||
|
const isToday = isSameDay(day, new Date());
|
||||||
|
const dayItems = getItemsForDate(day);
|
||||||
|
const totalHours = dayItems.reduce(
|
||||||
|
(acc, curr) => acc + curr.item.hours,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.toString()}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectDate(day)}
|
||||||
|
className={cn(
|
||||||
|
"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-primary bg-primary/5 ring-2 ring-offset-2"
|
||||||
|
: "bg-background/40",
|
||||||
|
isToday && !isSelected ? "bg-accent/40" : "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex w-full flex-col items-center border-b pb-4">
|
||||||
|
<span className="text-muted-foreground text-xs font-bold uppercase">
|
||||||
|
{format(day, "EEE")}
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-light">
|
||||||
|
{format(day, "d")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex-1 space-y-2 overflow-hidden">
|
||||||
|
{dayItems.length > 0 ? (
|
||||||
|
dayItems.map(({ item }, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
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 className="text-muted-foreground/20 flex h-full items-center justify-center">
|
||||||
|
<Plus className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dayItems.length > 0 && (
|
||||||
|
<div className="mt-auto w-full pt-2 text-center">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{totalHours}h Total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sheet for Day Details */}
|
||||||
|
<Sheet open={sheetOpen} onOpenChange={handleCloseSheet}>
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
className="flex w-full max-w-full flex-col gap-0 p-0 sm:w-[400px] sm:max-w-[540px]"
|
||||||
|
>
|
||||||
|
<SheetHeader className="border-b p-6">
|
||||||
|
<SheetTitle className="flex flex-wrap items-center gap-3 text-2xl">
|
||||||
|
<div className="bg-primary/10 flex-shrink-0 rounded-full p-2.5">
|
||||||
|
<CalendarIcon className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<span className="text-left break-words">
|
||||||
|
{date ? format(date, "EEEE, MMMM do") : "Details"}
|
||||||
|
</span>
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{date && selectedDateItems.length === 0 ? (
|
||||||
|
<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 rounded-full p-4 shadow-sm">
|
||||||
|
<Clock className="text-muted-foreground/50 h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-foreground text-lg font-semibold">
|
||||||
|
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>
|
||||||
|
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Log Time
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{selectedDateItems.map(({ item, index }) => (
|
||||||
|
<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">
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-muted-foreground text-xs">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateItem(index, "description", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Describe the work performed..."
|
||||||
|
className="pl-3 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hours and Rate in a row */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-muted-foreground text-xs">
|
||||||
|
Hours
|
||||||
|
</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={item.hours}
|
||||||
|
onChange={(v) => onUpdateItem(index, "hours", v)}
|
||||||
|
step={0.25}
|
||||||
|
min={0}
|
||||||
|
width="full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-muted-foreground text-xs">
|
||||||
|
Rate
|
||||||
|
</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={item.rate}
|
||||||
|
onChange={(v) => onUpdateItem(index, "rate", v)}
|
||||||
|
prefix="$"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
width="full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom section with controls, item name, and total */}
|
||||||
|
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onRemoveItem(index)}
|
||||||
|
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 px-3 text-center">
|
||||||
|
<span className="text-muted-foreground block text-sm font-medium">
|
||||||
|
Item #{index + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Total
|
||||||
|
</span>
|
||||||
|
<span className="text-primary text-lg font-bold">
|
||||||
|
${(item.hours * item.rate).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
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>
|
||||||
|
<span>Add Another Entry</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SheetFooter className="bg-muted/10 mt-auto border-t p-6">
|
||||||
|
<Button
|
||||||
|
className="h-12 w-full rounded-xl text-base shadow-md sm:w-full"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => handleCloseSheet(false)}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,12 +93,8 @@ function plainTextToHtml(value: string) {
|
|||||||
.replace(/\n/g, "<br>");
|
.replace(/\n/g, "<br>");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
function createDefaultInvoiceFormData(): InvoiceFormData {
|
||||||
const router = useRouter();
|
return {
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [formData, setFormData] = useState<InvoiceFormData>({
|
|
||||||
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: "",
|
||||||
@@ -121,7 +117,17 @@ 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);
|
||||||
@@ -153,6 +159,7 @@ 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(() => {
|
||||||
@@ -167,6 +174,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
|
||||||
setFormData({
|
setFormData({
|
||||||
invoiceNumber: existingInvoice.invoiceNumber,
|
invoiceNumber: existingInvoice.invoiceNumber,
|
||||||
invoicePrefix: existingInvoice.invoicePrefix ?? "#",
|
invoicePrefix: existingInvoice.invoicePrefix ?? "#",
|
||||||
|
|||||||
@@ -7,196 +7,212 @@ import { Textarea } from "~/components/ui/textarea";
|
|||||||
import { DatePicker } from "~/components/ui/date-picker";
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
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;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvoiceMetaSidebar({
|
export function InvoiceMetaSidebar({
|
||||||
formData,
|
formData,
|
||||||
updateField,
|
updateField,
|
||||||
clients,
|
clients,
|
||||||
businesses,
|
businesses,
|
||||||
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">
|
||||||
<Select
|
Status
|
||||||
value={formData.status}
|
</Label>
|
||||||
onValueChange={(value: "draft" | "sent" | "paid") =>
|
<Select
|
||||||
updateField("status", value)
|
value={formData.status}
|
||||||
}
|
onValueChange={(value: "draft" | "sent" | "paid") =>
|
||||||
>
|
updateField("status", value)
|
||||||
<SelectTrigger className="bg-background/50">
|
}
|
||||||
<SelectValue />
|
>
|
||||||
</SelectTrigger>
|
<SelectTrigger className="bg-background/50">
|
||||||
<SelectContent>
|
<SelectValue />
|
||||||
{STATUS_OPTIONS.map((option) => (
|
</SelectTrigger>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectContent>
|
||||||
{option.label}
|
{STATUS_OPTIONS.map((option) => (
|
||||||
</SelectItem>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
))}
|
{option.label}
|
||||||
</SelectContent>
|
</SelectItem>
|
||||||
</Select>
|
))}
|
||||||
</div>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
{/* Invoice Number */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="invoiceNumber" className="text-xs">Invoice Number</Label>
|
|
||||||
<Input
|
|
||||||
id="invoiceNumber"
|
|
||||||
value={formData.invoiceNumber}
|
|
||||||
placeholder="INV-..."
|
|
||||||
disabled
|
|
||||||
className="bg-muted/50 font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
|
|
||||||
Involved Parties
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* From (Business) */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="business" className="text-xs">From (Business)</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.businessId}
|
|
||||||
onValueChange={(value) => updateField("businessId", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger aria-label="From Business" className="bg-background/50 text-sm">
|
|
||||||
<span className="truncate">
|
|
||||||
<SelectValue placeholder="Select business" />
|
|
||||||
</span>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{businesses?.map((business) => (
|
|
||||||
<SelectItem key={business.id} value={business.id}>
|
|
||||||
{business.name}{business.nickname ? ` (${business.nickname})` : ""}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bill To (Client) */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="client" className="text-xs">Bill To (Client)</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.clientId}
|
|
||||||
onValueChange={(value) => updateField("clientId", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger aria-label="Bill To Client" className="bg-background/50 text-sm">
|
|
||||||
<span className="truncate">
|
|
||||||
<SelectValue placeholder="Select client" />
|
|
||||||
</span>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{clients?.map((client) => (
|
|
||||||
<SelectItem key={client.id} value={client.id}>
|
|
||||||
{client.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
|
|
||||||
Dates
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Issued</Label>
|
|
||||||
<DatePicker
|
|
||||||
date={formData.issueDate}
|
|
||||||
onDateChange={(date) => updateField("issueDate", date ?? new Date())}
|
|
||||||
className="w-full bg-background/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Due</Label>
|
|
||||||
<DatePicker
|
|
||||||
date={formData.dueDate}
|
|
||||||
onDateChange={(date) => updateField("dueDate", date ?? new Date())}
|
|
||||||
className="w-full bg-background/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
|
|
||||||
Config
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Tax Rate</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={formData.taxRate}
|
|
||||||
onChange={(v) => updateField("taxRate", v)}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
suffix="%"
|
|
||||||
className="bg-background/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Hourly Rate</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={formData.defaultHourlyRate ?? 0}
|
|
||||||
onChange={(v) => updateField("defaultHourlyRate", v)}
|
|
||||||
min={0}
|
|
||||||
prefix="$"
|
|
||||||
placeholder={!formData.clientId ? "Select client" : "Rate"}
|
|
||||||
disabled={!formData.clientId}
|
|
||||||
className={cn("bg-background/50", !formData.clientId && "opacity-50")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5 flex-1">
|
|
||||||
<Label className="text-xs">Notes</Label>
|
|
||||||
<Textarea
|
|
||||||
value={formData.notes}
|
|
||||||
onChange={(e) => updateField("notes", e.target.value)}
|
|
||||||
placeholder="Notes for client..."
|
|
||||||
className="bg-background/50 resize-none h-24"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
|
{/* Invoice Number */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="invoiceNumber" className="text-xs">
|
||||||
|
Invoice Number
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="invoiceNumber"
|
||||||
|
value={formData.invoiceNumber}
|
||||||
|
placeholder="INV-..."
|
||||||
|
disabled
|
||||||
|
className="bg-muted/50 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||||
|
Involved Parties
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* From (Business) */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="business" className="text-xs">
|
||||||
|
From (Business)
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.businessId}
|
||||||
|
onValueChange={(value) => updateField("businessId", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
aria-label="From Business"
|
||||||
|
className="bg-background/50 text-sm"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
<SelectValue placeholder="Select business" />
|
||||||
|
</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{businesses?.map((business) => (
|
||||||
|
<SelectItem key={business.id} value={business.id}>
|
||||||
|
{business.name}
|
||||||
|
{business.nickname ? ` (${business.nickname})` : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bill To (Client) */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="client" className="text-xs">
|
||||||
|
Bill To (Client)
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.clientId}
|
||||||
|
onValueChange={(value) => updateField("clientId", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
aria-label="Bill To Client"
|
||||||
|
className="bg-background/50 text-sm"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
<SelectValue placeholder="Select client" />
|
||||||
|
</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{clients?.map((client) => (
|
||||||
|
<SelectItem key={client.id} value={client.id}>
|
||||||
|
{client.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||||
|
Dates
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Issued</Label>
|
||||||
|
<DatePicker
|
||||||
|
date={formData.issueDate}
|
||||||
|
onDateChange={(date) =>
|
||||||
|
updateField("issueDate", date ?? new Date())
|
||||||
|
}
|
||||||
|
className="bg-background/50 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Due</Label>
|
||||||
|
<DatePicker
|
||||||
|
date={formData.dueDate}
|
||||||
|
onDateChange={(date) =>
|
||||||
|
updateField("dueDate", date ?? new Date())
|
||||||
|
}
|
||||||
|
className="bg-background/50 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||||
|
Config
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Tax Rate</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={formData.taxRate}
|
||||||
|
onChange={(v) => updateField("taxRate", v)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
suffix="%"
|
||||||
|
className="bg-background/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Hourly Rate</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={formData.defaultHourlyRate ?? 0}
|
||||||
|
onChange={(v) => updateField("defaultHourlyRate", v)}
|
||||||
|
min={0}
|
||||||
|
prefix="$"
|
||||||
|
placeholder={!formData.clientId ? "Select client" : "Rate"}
|
||||||
|
disabled={!formData.clientId}
|
||||||
|
className={cn(
|
||||||
|
"bg-background/50",
|
||||||
|
!formData.clientId && "opacity-50",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Label className="text-xs">Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) => updateField("notes", e.target.value)}
|
||||||
|
placeholder="Notes for client..."
|
||||||
|
className="bg-background/50 h-24 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,455 +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;
|
|
||||||
currency?: string | null;
|
|
||||||
client?: {
|
|
||||||
name: string;
|
|
||||||
email: string | null;
|
|
||||||
};
|
|
||||||
business?: {
|
|
||||||
name: string;
|
|
||||||
email: string | null;
|
|
||||||
};
|
|
||||||
items?: Array<{
|
|
||||||
id: string;
|
|
||||||
date?: Date;
|
|
||||||
description?: string;
|
|
||||||
hours: number;
|
|
||||||
rate: number;
|
|
||||||
amount?: 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't have an email address. Please add an
|
|
||||||
email address to the client before sending the invoice.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Branded Template Info */}
|
|
||||||
<Alert>
|
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
<strong>Professional Email Template:</strong> Your email will be
|
|
||||||
sent using a beautifully designed, beenvoice-branded template with
|
|
||||||
proper fonts and styling. Any custom content you add will be
|
|
||||||
incorporated into the professional template automatically.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={setActiveTab}
|
|
||||||
className="min-h-0 flex-1"
|
|
||||||
>
|
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
|
||||||
<TabsTrigger value="compose" className="flex items-center gap-2">
|
|
||||||
<Edit3 className="h-4 w-4" />
|
|
||||||
Compose
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="preview" className="flex items-center gap-2">
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
Preview
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="confirm"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
disabled={!isConfirming}
|
|
||||||
>
|
|
||||||
<CheckCircle className="h-4 w-4" />
|
|
||||||
Confirm
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<TabsContent
|
|
||||||
value="compose"
|
|
||||||
className="mt-4 h-full overflow-y-auto"
|
|
||||||
>
|
|
||||||
<EmailComposer
|
|
||||||
subject={subject}
|
|
||||||
onSubjectChange={setSubject}
|
|
||||||
content={emailContent}
|
|
||||||
onContentChange={setEmailContent}
|
|
||||||
customMessage={customMessage}
|
|
||||||
onCustomMessageChange={setCustomMessage}
|
|
||||||
fromEmail={fromEmail}
|
|
||||||
toEmail={toEmail}
|
|
||||||
ccEmail={ccEmail}
|
|
||||||
onCcEmailChange={setCcEmail}
|
|
||||||
bccEmail={bccEmail}
|
|
||||||
onBccEmailChange={setBccEmail}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent
|
|
||||||
value="preview"
|
|
||||||
className="mt-4 h-full overflow-y-auto"
|
|
||||||
>
|
|
||||||
<EmailPreview
|
|
||||||
subject={subject}
|
|
||||||
fromEmail={fromEmail}
|
|
||||||
toEmail={toEmail}
|
|
||||||
ccEmail={ccEmail}
|
|
||||||
bccEmail={bccEmail}
|
|
||||||
content={emailContent}
|
|
||||||
customMessage={customMessage}
|
|
||||||
invoice={invoice}
|
|
||||||
className="pr-2"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent
|
|
||||||
value="confirm"
|
|
||||||
className="mt-4 h-full overflow-y-auto"
|
|
||||||
>
|
|
||||||
<div className="space-y-6 pr-2">
|
|
||||||
<Alert>
|
|
||||||
<CheckCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
You're about to send this email to{" "}
|
|
||||||
<strong>{toEmail}</strong>. The invoice PDF will be
|
|
||||||
automatically attached.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<EmailPreview
|
|
||||||
subject={subject}
|
|
||||||
fromEmail={fromEmail}
|
|
||||||
toEmail={toEmail}
|
|
||||||
content={emailContent}
|
|
||||||
customMessage={customMessage}
|
|
||||||
invoice={invoice}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{invoice?.status === "draft" && (
|
|
||||||
<Alert>
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
This invoice is currently in <strong>draft</strong>{" "}
|
|
||||||
status. Sending it will automatically change the status to{" "}
|
|
||||||
<strong>sent</strong>.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<DialogFooter className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{activeTab === "compose" && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setActiveTab("preview")}
|
|
||||||
disabled={isSending}
|
|
||||||
>
|
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
|
||||||
Preview
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "preview" && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setActiveTab("compose")}
|
|
||||||
disabled={isSending}
|
|
||||||
>
|
|
||||||
<Edit3 className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleConfirmSend}
|
|
||||||
disabled={!canSend}
|
|
||||||
variant="default"
|
|
||||||
>
|
|
||||||
<CheckCircle className="mr-2 h-4 w-4" />
|
|
||||||
Review & Send
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "confirm" && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setActiveTab("preview")}
|
|
||||||
disabled={isSending}
|
|
||||||
>
|
|
||||||
Back to Preview
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSendEmail}
|
|
||||||
disabled={!canSend || isSending}
|
|
||||||
className="bg-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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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";
|
||||||
@@ -11,70 +14,75 @@ import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
|||||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||||
|
|
||||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||||
const { isCollapsed } = useSidebar();
|
const { isCollapsed } = useSidebar();
|
||||||
const { sidebarStyle } = useAppearance();
|
const { sidebarStyle } = useAppearance();
|
||||||
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
|
||||||
<Menu className="h-5 w-5" />
|
variant="outline"
|
||||||
<span className="sr-only">Toggle menu</span>
|
size="icon"
|
||||||
</Button>
|
className="bg-background h-10 w-10 shadow-sm"
|
||||||
</SheetTrigger>
|
suppressHydrationWarning
|
||||||
{/* Mobile Link / Logo */}
|
|
||||||
<div className="ml-4 flex items-center gap-2">
|
|
||||||
<Logo size="sm" />
|
|
||||||
</div>
|
|
||||||
<SheetContent side="left" className="p-0 w-72">
|
|
||||||
<div className="sr-only">
|
|
||||||
<h2 id="mobile-nav-title">Navigation Menu</h2>
|
|
||||||
</div>
|
|
||||||
<Sidebar mobile onClose={() => setIsMobileOpen(false)} />
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main
|
|
||||||
suppressHydrationWarning
|
|
||||||
className={cn(
|
|
||||||
"flex-1 min-h-screen min-w-0 transition-all duration-300 ease-in-out",
|
|
||||||
"md:ml-0",
|
|
||||||
sidebarStyle === "floating"
|
|
||||||
? isCollapsed
|
|
||||||
? "md:ml-24"
|
|
||||||
: "md:ml-[18rem]"
|
|
||||||
: isCollapsed
|
|
||||||
? "md:ml-16"
|
|
||||||
: "md:ml-64",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="p-4 pt-16 md:pt-4">
|
<Menu className="h-5 w-5" />
|
||||||
{/* Mobile header spacer is handled by pt-16 on mobile */}
|
<span className="sr-only">Toggle menu</span>
|
||||||
<div className="md:hidden mb-4">
|
</Button>
|
||||||
{/* Mobile Breadcrumbs could go here or be part of the page */}
|
</SheetTrigger>
|
||||||
</div>
|
{/* Mobile Link / Logo */}
|
||||||
{children}
|
<div className="ml-4 flex items-center gap-2">
|
||||||
</div>
|
<Logo size="sm" />
|
||||||
</main>
|
</div>
|
||||||
|
<SheetContent side="left" className="w-72 p-0">
|
||||||
|
<div className="sr-only">
|
||||||
|
<h2 id="mobile-nav-title">Navigation Menu</h2>
|
||||||
|
</div>
|
||||||
|
<Sidebar mobile onClose={() => setIsMobileOpen(false)} />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main
|
||||||
|
suppressHydrationWarning
|
||||||
|
className={cn(
|
||||||
|
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out",
|
||||||
|
"md:ml-0",
|
||||||
|
sidebarStyle === "floating"
|
||||||
|
? isCollapsed
|
||||||
|
? "md:ml-24"
|
||||||
|
: "md:ml-[18rem]"
|
||||||
|
: isCollapsed
|
||||||
|
? "md:ml-16"
|
||||||
|
: "md:ml-64",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="dashboard-content-shell p-4 pt-16 md:pt-4">
|
||||||
|
{/* Mobile header spacer is handled by pt-16 on mobile */}
|
||||||
|
<div className="mb-4 md:hidden">
|
||||||
|
{/* Mobile Breadcrumbs could go here or be part of the page */}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardShell({ children }: { children: React.ReactNode }) {
|
export function DashboardShell({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<DashboardContent>{children}</DashboardContent>
|
<DashboardContent>{children}</DashboardContent>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,25 @@
|
|||||||
import { cn } from "~/lib/utils";
|
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" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 +67,17 @@ export function Navbar() {
|
|||||||
Sign In
|
Sign In
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/auth/register">
|
{allowRegistration && (
|
||||||
<Button
|
<Link href="/auth/register">
|
||||||
size="sm"
|
<Button
|
||||||
variant="default"
|
size="sm"
|
||||||
className="text-xs font-medium md:text-sm"
|
variant="default"
|
||||||
>
|
className="text-xs font-medium md:text-sm"
|
||||||
Register
|
>
|
||||||
</Button>
|
Register
|
||||||
</Link>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function QuickActionCard({
|
|||||||
<CardContent className="p-6 text-center">
|
<CardContent className="p-6 text-center">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-auto mb-3 flex h-12 w-12 items-center justify-center transition-colors",
|
"mx-auto mb-3 flex h-12 w-12 items-center justify-center transition-colors",
|
||||||
styles.background,
|
styles.background,
|
||||||
styles.hoverBackground,
|
styles.hoverBackground,
|
||||||
)}
|
)}
|
||||||
@@ -101,7 +101,7 @@ export function QuickActionCardSkeleton() {
|
|||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
<div className="bg-muted mx-auto mb-3 h-12 w-12 "></div>
|
<div className="bg-muted mx-auto mb-3 h-12 w-12"></div>
|
||||||
<div className="bg-muted mx-auto mb-2 h-4 w-2/3 rounded"></div>
|
<div className="bg-muted mx-auto mb-2 h-4 w-2/3 rounded"></div>
|
||||||
<div className="bg-muted mx-auto h-3 w-1/2 rounded"></div>
|
<div className="bg-muted mx-auto h-3 w-1/2 rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,58 +3,54 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
interface SidebarContextType {
|
interface SidebarContextType {
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
toggleCollapse: () => void;
|
toggleCollapse: () => void;
|
||||||
expand: () => void;
|
expand: () => void;
|
||||||
collapse: () => void;
|
collapse: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarContext = React.createContext<SidebarContextType | undefined>(
|
const SidebarContext = React.createContext<SidebarContextType | undefined>(
|
||||||
undefined,
|
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;
|
||||||
|
const saved = localStorage.getItem("sidebar-collapsed");
|
||||||
|
return saved ? (JSON.parse(saved) as boolean) : false;
|
||||||
|
});
|
||||||
|
|
||||||
// Persist state if needed, for now just local state
|
const toggleCollapse = React.useCallback(() => {
|
||||||
React.useEffect(() => {
|
setIsCollapsed((prev) => {
|
||||||
const saved = localStorage.getItem("sidebar-collapsed");
|
const next = !prev;
|
||||||
if (saved) {
|
localStorage.setItem("sidebar-collapsed", JSON.stringify(next));
|
||||||
setIsCollapsed(JSON.parse(saved) as boolean);
|
return next;
|
||||||
}
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleCollapse = React.useCallback(() => {
|
const expand = React.useCallback(() => {
|
||||||
setIsCollapsed((prev) => {
|
setIsCollapsed(false);
|
||||||
const next = !prev;
|
localStorage.setItem("sidebar-collapsed", JSON.stringify(false));
|
||||||
localStorage.setItem("sidebar-collapsed", JSON.stringify(next));
|
}, []);
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const expand = React.useCallback(() => {
|
const collapse = React.useCallback(() => {
|
||||||
setIsCollapsed(false);
|
setIsCollapsed(true);
|
||||||
localStorage.setItem("sidebar-collapsed", JSON.stringify(false));
|
localStorage.setItem("sidebar-collapsed", JSON.stringify(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const collapse = React.useCallback(() => {
|
return (
|
||||||
setIsCollapsed(true);
|
<SidebarContext.Provider
|
||||||
localStorage.setItem("sidebar-collapsed", JSON.stringify(true));
|
value={{ isCollapsed, toggleCollapse, expand, collapse }}
|
||||||
}, []);
|
>
|
||||||
|
{children}
|
||||||
return (
|
</SidebarContext.Provider>
|
||||||
<SidebarContext.Provider
|
);
|
||||||
value={{ isCollapsed, toggleCollapse, expand, collapse }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SidebarContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSidebar() {
|
export function useSidebar() {
|
||||||
const context = React.useContext(SidebarContext);
|
const context = React.useContext(SidebarContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error("useSidebar must be used within a SidebarProvider");
|
throw new Error("useSidebar must be used within a SidebarProvider");
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
@@ -29,4 +32,4 @@ export function Breadcrumbs() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
|
|||||||
(_, i) => (
|
(_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex items-center gap-3 px-3 py-2.5"
|
className="flex items-center gap-3 px-3 py-2.5"
|
||||||
>
|
>
|
||||||
<Skeleton className="bg-muted/20 h-4 w-4" />
|
<Skeleton className="bg-muted/20 h-4 w-4" />
|
||||||
<Skeleton className="bg-muted/20 h-4 w-20" />
|
<Skeleton className="bg-muted/20 h-4 w-20" />
|
||||||
@@ -71,10 +71,11 @@ 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 ${
|
||||||
? "bg-primary/10 text-primary"
|
pathname === link.href
|
||||||
: "text-foreground hover:bg-muted"
|
? "bg-primary/10 text-primary"
|
||||||
}`}
|
: "text-foreground hover:bg-muted"
|
||||||
|
}`}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
|
|||||||
@@ -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 ??
|
||||||
@@ -216,10 +216,11 @@ export function AnimationPreferencesProvider({
|
|||||||
DEFAULT_PREFERS_REDUCED;
|
DEFAULT_PREFERS_REDUCED;
|
||||||
const finalSpeed = clampSpeed(
|
const finalSpeed = clampSpeed(
|
||||||
stored?.animationSpeedMultiplier ??
|
stored?.animationSpeedMultiplier ??
|
||||||
initial?.animationSpeedMultiplier ??
|
initial?.animationSpeedMultiplier ??
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,10 +176,9 @@ 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:
|
||||||
typeof parsed.pdfAccentColor === "string"
|
typeof parsed.pdfAccentColor === "string"
|
||||||
? parsed.pdfAccentColor
|
? parsed.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 (
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -54,13 +54,13 @@ function AlertDialogContent({
|
|||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,50 +1,50 @@
|
|||||||
"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>,
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AvatarPrimitive.Root
|
<AvatarPrimitive.Root
|
||||||
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>,
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AvatarPrimitive.Image
|
<AvatarPrimitive.Image
|
||||||
ref={ref}
|
ref={ref}
|
||||||
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>,
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const buttonVariants = cva(
|
|||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
props.mode !== "single" &&
|
||||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md"),
|
(props.showWeekNumber
|
||||||
defaultClassNames.day
|
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||||
|
: "[&:first-child[data-selected=true]_button]:rounded-l-md"),
|
||||||
|
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 };
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -3,13 +3,24 @@
|
|||||||
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({
|
||||||
const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 });
|
value,
|
||||||
const display = useTransform(spring, (current) => `${prefix}${current.toFixed(2)}${suffix}`);
|
prefix = "",
|
||||||
|
suffix = "",
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
}) {
|
||||||
|
const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 });
|
||||||
|
const display = useTransform(
|
||||||
|
spring,
|
||||||
|
(current) => `${prefix}${current.toFixed(2)}${suffix}`,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
spring.set(value);
|
spring.set(value);
|
||||||
}, [spring, value]);
|
}, [spring, value]);
|
||||||
|
|
||||||
return <motion.span>{display}</motion.span>;
|
return <motion.span>{display}</motion.span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,12 +60,13 @@ export function DatePicker({
|
|||||||
const inputWidthClass = className?.includes("w-full")
|
const inputWidthClass = className?.includes("w-full")
|
||||||
? "w-full"
|
? "w-full"
|
||||||
: className?.includes("w-32") ||
|
: className?.includes("w-32") ||
|
||||||
className?.includes("w-28") ||
|
className?.includes("w-28") ||
|
||||||
className?.includes("w-36")
|
className?.includes("w-36")
|
||||||
? className
|
? className
|
||||||
: "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}
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -60,8 +60,8 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function DropdownMenuContent({
|
|||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
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 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md",
|
"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 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -74,7 +74,7 @@ function DropdownMenuItem({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-foreground-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-foreground-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
|
|||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
|
|||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-foreground-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground-foreground flex cursor-default items-center px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
"focus:bg-accent focus:text-foreground-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground-foreground flex cursor-default items-center px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -230,7 +230,7 @@ function DropdownMenuSubContent({
|
|||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden border-0 shadow-lg",
|
"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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden border-0 shadow-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -4,34 +4,34 @@ import { cn } from "~/lib/utils";
|
|||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
|
|
||||||
interface ImageWithSkeletonProps extends ImageProps {
|
interface ImageWithSkeletonProps extends ImageProps {
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageWithSkeleton({
|
export function ImageWithSkeleton({
|
||||||
className,
|
className,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
alt,
|
alt,
|
||||||
...props
|
...props
|
||||||
}: ImageWithSkeletonProps) {
|
}: ImageWithSkeletonProps) {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative overflow-hidden", containerClassName)}>
|
<div className={cn("relative overflow-hidden", containerClassName)}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<Skeleton className="absolute inset-0 h-full w-full animate-pulse" />
|
<Skeleton className="absolute inset-0 h-full w-full animate-pulse" />
|
||||||
)}
|
)}
|
||||||
<Image
|
<Image
|
||||||
className={cn(
|
className={cn(
|
||||||
"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}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -129,12 +129,12 @@ function NavigationMenuLink({
|
|||||||
<NavigationMenuPrimitive.Link
|
<NavigationMenuPrimitive.Link
|
||||||
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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -30,19 +30,19 @@ function PopoverContent({
|
|||||||
align={align}
|
align={align}
|
||||||
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 };
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function Progress({
|
|||||||
<ProgressPrimitive.Root
|
<ProgressPrimitive.Root
|
||||||
data-slot="progress"
|
data-slot="progress"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-primary/20 relative h-2 w-full overflow-hidden ",
|
"bg-primary/20 relative h-2 w-full overflow-hidden",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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,12 +12,9 @@ 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" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -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" />
|
||||||
@@ -69,7 +58,7 @@ export function DashboardCardsSkeleton() {
|
|||||||
|
|
||||||
export function DashboardActivitySkeleton() {
|
export function DashboardActivitySkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className=" border border-gray-100 bg-white p-6 shadow-sm">
|
<div 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" />
|
||||||
@@ -81,17 +70,17 @@ export function DashboardActivitySkeleton() {
|
|||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex items-center justify-between border border-gray-100 p-4"
|
className="flex items-center justify-between border border-gray-100 p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="h-8 w-8 " />
|
<Skeleton className="h-8 w-8" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-4 w-24" />
|
||||||
<Skeleton className="h-3 w-32" />
|
<Skeleton className="h-3 w-32" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="h-6 w-16 " />
|
<Skeleton className="h-6 w-16" />
|
||||||
<Skeleton className="h-4 w-16" />
|
<Skeleton className="h-4 w-16" />
|
||||||
<Skeleton className="h-8 w-8 rounded" />
|
<Skeleton className="h-8 w-8 rounded" />
|
||||||
</div>
|
</div>
|
||||||
@@ -115,14 +104,14 @@ export function DashboardHeroSkeleton() {
|
|||||||
|
|
||||||
export function QuickActionsSkeleton() {
|
export function QuickActionsSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className=" border border-gray-100 bg-white p-6 shadow-sm">
|
<div className="border border-gray-100 bg-white p-6 shadow-sm">
|
||||||
<div className="mb-4 flex items-center gap-2">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
<Skeleton className="h-5 w-5 rounded" />
|
<Skeleton className="h-5 w-5 rounded" />
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<div key={i} className=" border border-gray-200 p-4">
|
<div key={i} className="border border-gray-200 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="h-5 w-5" />
|
<Skeleton className="h-5 w-5" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,6 @@ import { genericOAuthClient } from "better-auth/client/plugins";
|
|||||||
* Auth client configuration
|
* Auth client configuration
|
||||||
*/
|
*/
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: process.env.NEXT_PUBLIC_APP_URL,
|
baseURL: process.env.NEXT_PUBLIC_APP_URL,
|
||||||
plugins: [genericOAuthClient()],
|
plugins: [genericOAuthClient()],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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 ?? "$",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
+15
-10
@@ -36,8 +36,9 @@ 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.h})`,
|
base.c * 0.2
|
||||||
|
} ${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})`,
|
||||||
"--muted": `oklch(0.95 ${base.c * 0.2} ${base.h})`,
|
"--muted": `oklch(0.95 ${base.c * 0.2} ${base.h})`,
|
||||||
@@ -56,8 +57,9 @@ 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.h})`,
|
base.c * 0.2
|
||||||
|
} ${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})`,
|
||||||
"--sidebar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
|
"--sidebar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
|
||||||
@@ -75,11 +77,13 @@ 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.h})`,
|
base.c * 0.2
|
||||||
|
} ${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.h})`,
|
base.c * 0.2
|
||||||
|
} ${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})`,
|
||||||
"--accent": `oklch(0.3 ${base.c * 0.5} ${base.h})`,
|
"--accent": `oklch(0.3 ${base.c * 0.5} ${base.h})`,
|
||||||
@@ -96,8 +100,9 @@ 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.h})`,
|
base.c * 0.2
|
||||||
|
} ${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})`,
|
||||||
"--sidebar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
|
"--sidebar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
export function getGravatarUrl(email: string, size = 200) {
|
export function getGravatarUrl(email: string, size = 200) {
|
||||||
const trimmedEmail = email.trim().toLowerCase();
|
const trimmedEmail = email.trim().toLowerCase();
|
||||||
const hash = createHash("sha256").update(trimmedEmail).digest("hex");
|
const hash = createHash("sha256").update(trimmedEmail).digest("hex");
|
||||||
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`;
|
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+595
-159
@@ -54,7 +54,7 @@ function downloadBlob(blob: Blob, filename: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InvoiceData {
|
export interface InvoiceData {
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
invoicePrefix?: string | null;
|
invoicePrefix?: string | null;
|
||||||
issueDate: Date;
|
issueDate: Date;
|
||||||
@@ -537,6 +537,170 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const minimalStyles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
fontSize: 9,
|
||||||
|
paddingTop: 28,
|
||||||
|
paddingBottom: 48,
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
denseHeader: {
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
headerTop: {
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
businessName: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
businessInfo: {
|
||||||
|
fontSize: 8,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
marginBottom: 1,
|
||||||
|
},
|
||||||
|
businessAddress: {
|
||||||
|
fontSize: 8,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
invoiceTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
marginBottom: 3,
|
||||||
|
},
|
||||||
|
invoiceNumber: {
|
||||||
|
fontSize: 10,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
paddingHorizontal: 0,
|
||||||
|
paddingVertical: 0,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
fontSize: 8,
|
||||||
|
},
|
||||||
|
headerSeparator: {
|
||||||
|
marginVertical: 4,
|
||||||
|
},
|
||||||
|
detailsSection: {
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
detailsColumn: {
|
||||||
|
marginRight: 14,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 8,
|
||||||
|
marginBottom: 5,
|
||||||
|
},
|
||||||
|
clientName: {
|
||||||
|
fontSize: 9,
|
||||||
|
marginBottom: 1,
|
||||||
|
},
|
||||||
|
clientInfo: {
|
||||||
|
fontSize: 8,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
marginBottom: 1,
|
||||||
|
},
|
||||||
|
clientAddress: {
|
||||||
|
fontSize: 8,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
detailRow: {
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
detailLabel: {
|
||||||
|
fontSize: 8,
|
||||||
|
},
|
||||||
|
detailValue: {
|
||||||
|
fontSize: 8,
|
||||||
|
},
|
||||||
|
tableContainer: {
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
tableHeader: {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
paddingVertical: 4,
|
||||||
|
paddingHorizontal: 0,
|
||||||
|
},
|
||||||
|
tableHeaderCell: {
|
||||||
|
fontSize: 8,
|
||||||
|
paddingHorizontal: 2,
|
||||||
|
},
|
||||||
|
tableRow: {
|
||||||
|
paddingVertical: 3,
|
||||||
|
minHeight: 16,
|
||||||
|
},
|
||||||
|
tableCell: {
|
||||||
|
fontSize: 8,
|
||||||
|
paddingHorizontal: 2,
|
||||||
|
paddingVertical: 1,
|
||||||
|
},
|
||||||
|
tableCellDescription: {
|
||||||
|
lineHeight: 1.2,
|
||||||
|
paddingVertical: 1,
|
||||||
|
paddingHorizontal: 2,
|
||||||
|
},
|
||||||
|
bottomSection: {
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
notesContainer: {
|
||||||
|
width: 260,
|
||||||
|
},
|
||||||
|
notesSection: {
|
||||||
|
padding: 0,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
},
|
||||||
|
notesTitle: {
|
||||||
|
fontSize: 8,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
notesContent: {
|
||||||
|
fontSize: 8,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
},
|
||||||
|
totalsContainer: {
|
||||||
|
width: 190,
|
||||||
|
},
|
||||||
|
totalsBox: {
|
||||||
|
padding: 0,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
},
|
||||||
|
totalRow: {
|
||||||
|
marginBottom: 2,
|
||||||
|
paddingVertical: 0,
|
||||||
|
},
|
||||||
|
totalLabel: {
|
||||||
|
fontSize: 8,
|
||||||
|
},
|
||||||
|
totalAmount: {
|
||||||
|
fontSize: 8,
|
||||||
|
},
|
||||||
|
finalTotalRow: {
|
||||||
|
marginTop: 5,
|
||||||
|
paddingTop: 5,
|
||||||
|
},
|
||||||
|
finalTotalLabel: {
|
||||||
|
fontSize: 9,
|
||||||
|
},
|
||||||
|
finalTotalAmount: {
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
itemCount: {
|
||||||
|
fontSize: 7,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
bottom: 20,
|
||||||
|
left: 32,
|
||||||
|
right: 32,
|
||||||
|
paddingTop: 7,
|
||||||
|
},
|
||||||
|
pageNumber: {
|
||||||
|
fontSize: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
const formatCurrency = (amount: number, currency = "USD") => {
|
const formatCurrency = (amount: number, currency = "USD") => {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
@@ -602,109 +766,264 @@ function getColumnWidths(showRate: boolean) {
|
|||||||
const DenseHeader: React.FC<{
|
const DenseHeader: React.FC<{
|
||||||
invoice: InvoiceData;
|
invoice: InvoiceData;
|
||||||
settings: Required<PDFGenerationSettings>;
|
settings: Required<PDFGenerationSettings>;
|
||||||
}> = ({ invoice, settings }) => (
|
}> = ({ invoice, settings }) => {
|
||||||
<View style={styles.denseHeader}>
|
const isMinimal = settings.pdfTemplate === "minimal";
|
||||||
<View style={styles.headerTop}>
|
|
||||||
<View style={styles.businessSection}>
|
return (
|
||||||
<Text style={[styles.businessName, { color: settings.pdfAccentColor }]}>
|
<View
|
||||||
{invoice.business?.name ?? "Your Business Name"}
|
style={[styles.denseHeader, isMinimal ? minimalStyles.denseHeader : {}]}
|
||||||
</Text>
|
>
|
||||||
{invoice.business?.email && (
|
<View
|
||||||
<Text style={styles.businessInfo}>{invoice.business.email}</Text>
|
style={[styles.headerTop, isMinimal ? minimalStyles.headerTop : {}]}
|
||||||
)}
|
>
|
||||||
{invoice.business?.phone && (
|
<View style={styles.businessSection}>
|
||||||
<Text style={styles.businessInfo}>{invoice.business.phone}</Text>
|
<Text
|
||||||
)}
|
style={[
|
||||||
{(invoice.business?.addressLine1 ??
|
styles.businessName,
|
||||||
invoice.business?.city ??
|
isMinimal ? minimalStyles.businessName : {},
|
||||||
invoice.business?.state) && (
|
{ color: settings.pdfAccentColor },
|
||||||
<Text style={styles.businessAddress}>
|
]}
|
||||||
{[
|
>
|
||||||
invoice.business?.addressLine1,
|
{invoice.business?.name ?? "Your Business Name"}
|
||||||
invoice.business?.addressLine2,
|
|
||||||
invoice.business?.city &&
|
|
||||||
invoice.business?.state &&
|
|
||||||
invoice.business?.postalCode
|
|
||||||
? `${invoice.business.city}, ${invoice.business.state} ${invoice.business.postalCode}`
|
|
||||||
: [
|
|
||||||
invoice.business?.city,
|
|
||||||
invoice.business?.state,
|
|
||||||
invoice.business?.postalCode,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", "),
|
|
||||||
invoice.business?.country,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n")}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
{invoice.business?.email && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.businessInfo,
|
||||||
|
isMinimal ? minimalStyles.businessInfo : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{invoice.business.email}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{invoice.business?.phone && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.businessInfo,
|
||||||
|
isMinimal ? minimalStyles.businessInfo : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{invoice.business.phone}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{(invoice.business?.addressLine1 ??
|
||||||
|
invoice.business?.city ??
|
||||||
|
invoice.business?.state) && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.businessAddress,
|
||||||
|
isMinimal ? minimalStyles.businessAddress : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
invoice.business?.addressLine1,
|
||||||
|
invoice.business?.addressLine2,
|
||||||
|
invoice.business?.city &&
|
||||||
|
invoice.business?.state &&
|
||||||
|
invoice.business?.postalCode
|
||||||
|
? `${invoice.business.city}, ${invoice.business.state} ${invoice.business.postalCode}`
|
||||||
|
: [
|
||||||
|
invoice.business?.city,
|
||||||
|
invoice.business?.state,
|
||||||
|
invoice.business?.postalCode,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", "),
|
||||||
|
invoice.business?.country,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.invoiceSection}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.invoiceTitle,
|
||||||
|
isMinimal ? minimalStyles.invoiceTitle : {},
|
||||||
|
{ color: settings.pdfAccentColor },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
INVOICE
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.invoiceNumber,
|
||||||
|
isMinimal ? minimalStyles.invoiceNumber : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{invoice.invoicePrefix ?? "#"}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
...getStatusStyle(invoice.status),
|
||||||
|
isMinimal ? minimalStyles.statusBadge : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text>{getStatusLabel(invoice.status)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.invoiceSection}>
|
<View
|
||||||
<Text style={[styles.invoiceTitle, { color: settings.pdfAccentColor }]}>
|
style={[
|
||||||
INVOICE
|
styles.headerSeparator,
|
||||||
</Text>
|
isMinimal ? minimalStyles.headerSeparator : {},
|
||||||
<Text style={styles.invoiceNumber}>
|
]}
|
||||||
{invoice.invoicePrefix ?? "#"}
|
/>
|
||||||
{invoice.invoiceNumber}
|
|
||||||
</Text>
|
<View
|
||||||
<View style={getStatusStyle(invoice.status)}>
|
style={[
|
||||||
<Text>{getStatusLabel(invoice.status)}</Text>
|
styles.detailsSection,
|
||||||
|
isMinimal ? minimalStyles.detailsSection : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.detailsColumn,
|
||||||
|
isMinimal ? minimalStyles.detailsColumn : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.sectionTitle,
|
||||||
|
isMinimal ? minimalStyles.sectionTitle : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
BILL TO:
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.clientName,
|
||||||
|
isMinimal ? minimalStyles.clientName : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{invoice.client?.name ?? "N/A"}
|
||||||
|
</Text>
|
||||||
|
{invoice.client?.email && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.clientInfo,
|
||||||
|
isMinimal ? minimalStyles.clientInfo : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{invoice.client.email}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{invoice.client?.phone && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.clientInfo,
|
||||||
|
isMinimal ? minimalStyles.clientInfo : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{invoice.client.phone}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{(invoice.client?.addressLine1 ??
|
||||||
|
invoice.client?.city ??
|
||||||
|
invoice.client?.state) && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.clientAddress,
|
||||||
|
isMinimal ? minimalStyles.clientAddress : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
invoice.client?.addressLine1,
|
||||||
|
invoice.client?.addressLine2,
|
||||||
|
invoice.client?.city,
|
||||||
|
invoice.client?.state,
|
||||||
|
invoice.client?.postalCode,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}
|
||||||
|
{invoice.client?.country ? "\n" + invoice.client.country : ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.detailsColumn,
|
||||||
|
isMinimal ? minimalStyles.detailsColumn : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.sectionTitle,
|
||||||
|
isMinimal ? minimalStyles.sectionTitle : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
INVOICE DETAILS:
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={[styles.detailRow, isMinimal ? minimalStyles.detailRow : {}]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.detailLabel,
|
||||||
|
isMinimal ? minimalStyles.detailLabel : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Issue Date:
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.detailValue,
|
||||||
|
isMinimal ? minimalStyles.detailValue : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{formatDate(invoice.issueDate)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={[styles.detailRow, isMinimal ? minimalStyles.detailRow : {}]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.detailLabel,
|
||||||
|
isMinimal ? minimalStyles.detailLabel : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Due Date:
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.detailValue,
|
||||||
|
isMinimal ? minimalStyles.detailValue : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{formatDate(invoice.dueDate)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={[styles.detailRow, isMinimal ? minimalStyles.detailRow : {}]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.detailLabel,
|
||||||
|
isMinimal ? minimalStyles.detailLabel : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Invoice #:
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.detailValue,
|
||||||
|
isMinimal ? minimalStyles.detailValue : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
);
|
||||||
<View style={styles.headerSeparator} />
|
};
|
||||||
|
|
||||||
<View style={styles.detailsSection}>
|
|
||||||
<View style={styles.detailsColumn}>
|
|
||||||
<Text style={styles.sectionTitle}>BILL TO:</Text>
|
|
||||||
<Text style={styles.clientName}>{invoice.client?.name ?? "N/A"}</Text>
|
|
||||||
{invoice.client?.email && (
|
|
||||||
<Text style={styles.clientInfo}>{invoice.client.email}</Text>
|
|
||||||
)}
|
|
||||||
{invoice.client?.phone && (
|
|
||||||
<Text style={styles.clientInfo}>{invoice.client.phone}</Text>
|
|
||||||
)}
|
|
||||||
{(invoice.client?.addressLine1 ??
|
|
||||||
invoice.client?.city ??
|
|
||||||
invoice.client?.state) && (
|
|
||||||
<Text style={styles.clientAddress}>
|
|
||||||
{[
|
|
||||||
invoice.client?.addressLine1,
|
|
||||||
invoice.client?.addressLine2,
|
|
||||||
invoice.client?.city,
|
|
||||||
invoice.client?.state,
|
|
||||||
invoice.client?.postalCode,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ")}
|
|
||||||
{invoice.client?.country ? "\n" + invoice.client.country : ""}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.detailsColumn}>
|
|
||||||
<Text style={styles.sectionTitle}>INVOICE DETAILS:</Text>
|
|
||||||
<View style={styles.detailRow}>
|
|
||||||
<Text style={styles.detailLabel}>Issue Date:</Text>
|
|
||||||
<Text style={styles.detailValue}>
|
|
||||||
{formatDate(invoice.issueDate)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.detailRow}>
|
|
||||||
<Text style={styles.detailLabel}>Due Date:</Text>
|
|
||||||
<Text style={styles.detailValue}>{formatDate(invoice.dueDate)}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.detailRow}>
|
|
||||||
<Text style={styles.detailLabel}>Invoice #:</Text>
|
|
||||||
<Text style={styles.detailValue}>{invoice.invoiceNumber}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Table header component
|
// Table header component
|
||||||
const TableHeader: React.FC<{
|
const TableHeader: React.FC<{
|
||||||
@@ -712,22 +1031,33 @@ const TableHeader: React.FC<{
|
|||||||
showRate: boolean;
|
showRate: boolean;
|
||||||
}> = ({ settings, showRate }) => {
|
}> = ({ settings, showRate }) => {
|
||||||
const cols = getColumnWidths(showRate);
|
const cols = getColumnWidths(showRate);
|
||||||
|
const isMinimal = settings.pdfTemplate === "minimal";
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[styles.tableHeader, isMinimal ? minimalStyles.tableHeader : {}]}
|
||||||
styles.tableHeader,
|
|
||||||
settings.pdfTemplate === "minimal"
|
|
||||||
? { backgroundColor: "#ffffff" }
|
|
||||||
: {},
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text>
|
<Text
|
||||||
<Text style={[styles.tableHeaderCell, { width: cols.description }]}>
|
style={[
|
||||||
|
styles.tableHeaderCell,
|
||||||
|
isMinimal ? minimalStyles.tableHeaderCell : {},
|
||||||
|
{ width: cols.date },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Date
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tableHeaderCell,
|
||||||
|
isMinimal ? minimalStyles.tableHeaderCell : {},
|
||||||
|
{ width: cols.description },
|
||||||
|
]}
|
||||||
|
>
|
||||||
Description
|
Description
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tableHeaderCell,
|
styles.tableHeaderCell,
|
||||||
|
isMinimal ? minimalStyles.tableHeaderCell : {},
|
||||||
styles.tableHeaderHours,
|
styles.tableHeaderHours,
|
||||||
{ width: cols.hours },
|
{ width: cols.hours },
|
||||||
]}
|
]}
|
||||||
@@ -738,6 +1068,7 @@ const TableHeader: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tableHeaderCell,
|
styles.tableHeaderCell,
|
||||||
|
isMinimal ? minimalStyles.tableHeaderCell : {},
|
||||||
styles.tableHeaderRate,
|
styles.tableHeaderRate,
|
||||||
{ width: cols.rate },
|
{ width: cols.rate },
|
||||||
]}
|
]}
|
||||||
@@ -748,6 +1079,7 @@ const TableHeader: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tableHeaderCell,
|
styles.tableHeaderCell,
|
||||||
|
isMinimal ? minimalStyles.tableHeaderCell : {},
|
||||||
styles.tableHeaderAmount,
|
styles.tableHeaderAmount,
|
||||||
{ width: cols.amount },
|
{ width: cols.amount },
|
||||||
]}
|
]}
|
||||||
@@ -759,14 +1091,39 @@ const TableHeader: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Footer component
|
// Footer component
|
||||||
const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
const NotesSection: React.FC<{
|
||||||
|
invoice: InvoiceData;
|
||||||
|
settings: Required<PDFGenerationSettings>;
|
||||||
|
}> = ({ invoice, settings }) => {
|
||||||
if (!invoice.notes) return null;
|
if (!invoice.notes) return null;
|
||||||
|
const isMinimal = settings.pdfTemplate === "minimal";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.notesContainer}>
|
<View
|
||||||
<View style={styles.notesSection}>
|
style={[
|
||||||
<Text style={styles.notesTitle}>NOTES</Text>
|
styles.notesContainer,
|
||||||
<Text style={styles.notesContent}>{invoice.notes}</Text>
|
isMinimal ? minimalStyles.notesContainer : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.notesSection,
|
||||||
|
isMinimal ? minimalStyles.notesSection : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.notesTitle, isMinimal ? minimalStyles.notesTitle : {}]}
|
||||||
|
>
|
||||||
|
NOTES
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.notesContent,
|
||||||
|
isMinimal ? minimalStyles.notesContent : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{invoice.notes}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -774,40 +1131,45 @@ const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
|||||||
|
|
||||||
const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
|
const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
|
||||||
settings,
|
settings,
|
||||||
}) => (
|
}) => {
|
||||||
<View style={styles.footer} fixed>
|
const isMinimal = settings.pdfTemplate === "minimal";
|
||||||
<View style={styles.footerLogo}>
|
|
||||||
{settings.pdfShowLogo && (
|
return (
|
||||||
<Image
|
<View style={[styles.footer, isMinimal ? minimalStyles.footer : {}]} fixed>
|
||||||
src="/beenvoice-logo.png"
|
<View style={styles.footerLogo}>
|
||||||
|
{settings.pdfShowLogo && (
|
||||||
|
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image does not support alt.
|
||||||
|
<Image
|
||||||
|
src="/beenvoice-logo.png"
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 18,
|
||||||
|
marginRight: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
style={{
|
style={{
|
||||||
width: 120,
|
fontSize: isMinimal ? 8 : 9,
|
||||||
height: 18,
|
fontFamily: "Helvetica",
|
||||||
marginRight: 8,
|
color: "#6b7280",
|
||||||
|
marginLeft: settings.pdfShowLogo ? 8 : 0,
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
{settings.pdfFooterText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{settings.pdfShowPageNumbers && (
|
||||||
|
<Text
|
||||||
|
style={[styles.pageNumber, isMinimal ? minimalStyles.pageNumber : {}]}
|
||||||
|
render={({ pageNumber, totalPages }) =>
|
||||||
|
`Page ${pageNumber} of ${totalPages}`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 9,
|
|
||||||
fontFamily: "Helvetica",
|
|
||||||
color: "#6b7280",
|
|
||||||
marginLeft: settings.pdfShowLogo ? 8 : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{settings.pdfFooterText}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
{settings.pdfShowPageNumbers && (
|
);
|
||||||
<Text
|
};
|
||||||
style={styles.pageNumber}
|
|
||||||
render={({ pageNumber, totalPages }) =>
|
|
||||||
`Page ${pageNumber} of ${totalPages}`
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enhanced totals section component
|
// Enhanced totals section component
|
||||||
const TotalsSection: React.FC<{
|
const TotalsSection: React.FC<{
|
||||||
@@ -819,14 +1181,21 @@ const TotalsSection: React.FC<{
|
|||||||
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
|
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
|
||||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||||
const total = subtotal + taxAmount;
|
const total = subtotal + taxAmount;
|
||||||
|
const isMinimal = settings.pdfTemplate === "minimal";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.totalsContainer}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.totalsContainer,
|
||||||
|
isMinimal ? minimalStyles.totalsContainer : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.totalsBox,
|
styles.totalsBox,
|
||||||
settings.pdfTemplate === "minimal"
|
isMinimal
|
||||||
? {
|
? {
|
||||||
|
...minimalStyles.totalsBox,
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
borderTop: "1px solid #e5e7eb",
|
borderTop: "1px solid #e5e7eb",
|
||||||
paddingHorizontal: 0,
|
paddingHorizontal: 0,
|
||||||
@@ -836,38 +1205,79 @@ const TotalsSection: React.FC<{
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: isMinimal ? 8 : 11,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
textAlign: "center",
|
textAlign: isMinimal ? "left" : "center",
|
||||||
marginBottom: 8,
|
marginBottom: isMinimal ? 5 : 8,
|
||||||
paddingBottom: 6,
|
paddingBottom: isMinimal ? 3 : 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
INVOICE SUMMARY
|
INVOICE SUMMARY
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={styles.totalRow}>
|
<View
|
||||||
<Text style={styles.totalLabel}>Subtotal:</Text>
|
style={[styles.totalRow, isMinimal ? minimalStyles.totalRow : {}]}
|
||||||
<Text style={styles.totalAmount}>
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.totalLabel,
|
||||||
|
isMinimal ? minimalStyles.totalLabel : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Subtotal:
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.totalAmount,
|
||||||
|
isMinimal ? minimalStyles.totalAmount : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
{formatCurrency(subtotal, currency)}
|
{formatCurrency(subtotal, currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{invoice.taxRate > 0 && (
|
{invoice.taxRate > 0 && (
|
||||||
<View style={styles.totalRow}>
|
<View
|
||||||
<Text style={styles.totalLabel}>Tax ({invoice.taxRate}%):</Text>
|
style={[styles.totalRow, isMinimal ? minimalStyles.totalRow : {}]}
|
||||||
<Text style={styles.totalAmount}>
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.totalLabel,
|
||||||
|
isMinimal ? minimalStyles.totalLabel : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Tax ({invoice.taxRate}%):
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.totalAmount,
|
||||||
|
isMinimal ? minimalStyles.totalAmount : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
{formatCurrency(taxAmount, currency)}
|
{formatCurrency(taxAmount, currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.finalTotalRow}>
|
<View
|
||||||
<Text style={styles.finalTotalLabel}>TOTAL:</Text>
|
style={[
|
||||||
|
styles.finalTotalRow,
|
||||||
|
isMinimal ? minimalStyles.finalTotalRow : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.finalTotalLabel,
|
||||||
|
isMinimal ? minimalStyles.finalTotalLabel : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
TOTAL:
|
||||||
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.finalTotalAmount,
|
styles.finalTotalAmount,
|
||||||
|
isMinimal ? minimalStyles.finalTotalAmount : {},
|
||||||
{ color: settings.pdfAccentColor },
|
{ color: settings.pdfAccentColor },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -875,7 +1285,9 @@ const TotalsSection: React.FC<{
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.itemCount}>
|
<Text
|
||||||
|
style={[styles.itemCount, isMinimal ? minimalStyles.itemCount : {}]}
|
||||||
|
>
|
||||||
{items.length} line item{items.length !== 1 ? "s" : ""}
|
{items.length} line item{items.length !== 1 ? "s" : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -893,14 +1305,23 @@ export const InvoicePDF: React.FC<{
|
|||||||
const currency = invoice.currency ?? "USD";
|
const currency = invoice.currency ?? "USD";
|
||||||
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
|
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
|
||||||
const cols = getColumnWidths(showRate);
|
const cols = getColumnWidths(showRate);
|
||||||
|
const isMinimal = settings.pdfTemplate === "minimal";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Document>
|
<Document>
|
||||||
<Page size="LETTER" style={styles.page}>
|
<Page
|
||||||
|
size="LETTER"
|
||||||
|
style={[styles.page, isMinimal ? minimalStyles.page : {}]}
|
||||||
|
>
|
||||||
<DenseHeader invoice={invoice} settings={settings} />
|
<DenseHeader invoice={invoice} settings={settings} />
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<View style={styles.tableContainer}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.tableContainer,
|
||||||
|
isMinimal ? minimalStyles.tableContainer : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
<TableHeader settings={settings} showRate={showRate} />
|
<TableHeader settings={settings} showRate={showRate} />
|
||||||
{items.map(
|
{items.map(
|
||||||
(item, index) =>
|
(item, index) =>
|
||||||
@@ -910,6 +1331,7 @@ export const InvoicePDF: React.FC<{
|
|||||||
wrap={false}
|
wrap={false}
|
||||||
style={[
|
style={[
|
||||||
styles.tableRow,
|
styles.tableRow,
|
||||||
|
isMinimal ? minimalStyles.tableRow : {},
|
||||||
settings.pdfTemplate === "classic" && index % 2 === 0
|
settings.pdfTemplate === "classic" && index % 2 === 0
|
||||||
? styles.tableRowAlt
|
? styles.tableRowAlt
|
||||||
: {},
|
: {},
|
||||||
@@ -918,6 +1340,7 @@ export const InvoicePDF: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tableCell,
|
styles.tableCell,
|
||||||
|
isMinimal ? minimalStyles.tableCell : {},
|
||||||
styles.tableCellDate,
|
styles.tableCellDate,
|
||||||
{ width: cols.date },
|
{ width: cols.date },
|
||||||
]}
|
]}
|
||||||
@@ -927,7 +1350,9 @@ export const InvoicePDF: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tableCell,
|
styles.tableCell,
|
||||||
|
isMinimal ? minimalStyles.tableCell : {},
|
||||||
styles.tableCellDescription,
|
styles.tableCellDescription,
|
||||||
|
isMinimal ? minimalStyles.tableCellDescription : {},
|
||||||
{ width: cols.description },
|
{ width: cols.description },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -936,6 +1361,7 @@ export const InvoicePDF: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tableCell,
|
styles.tableCell,
|
||||||
|
isMinimal ? minimalStyles.tableCell : {},
|
||||||
styles.tableCellHours,
|
styles.tableCellHours,
|
||||||
{ width: cols.hours },
|
{ width: cols.hours },
|
||||||
]}
|
]}
|
||||||
@@ -946,6 +1372,7 @@ export const InvoicePDF: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tableCell,
|
styles.tableCell,
|
||||||
|
isMinimal ? minimalStyles.tableCell : {},
|
||||||
styles.tableCellRate,
|
styles.tableCellRate,
|
||||||
{ width: cols.rate },
|
{ width: cols.rate },
|
||||||
]}
|
]}
|
||||||
@@ -956,6 +1383,7 @@ export const InvoicePDF: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tableCell,
|
styles.tableCell,
|
||||||
|
isMinimal ? minimalStyles.tableCell : {},
|
||||||
styles.tableCellAmount,
|
styles.tableCellAmount,
|
||||||
{ width: cols.amount },
|
{ width: cols.amount },
|
||||||
]}
|
]}
|
||||||
@@ -968,8 +1396,16 @@ export const InvoicePDF: React.FC<{
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.bottomSection} wrap={false}>
|
<View
|
||||||
{invoice.notes && <NotesSection invoice={invoice} />}
|
style={[
|
||||||
|
styles.bottomSection,
|
||||||
|
isMinimal ? minimalStyles.bottomSection : {},
|
||||||
|
]}
|
||||||
|
wrap={false}
|
||||||
|
>
|
||||||
|
{invoice.notes && (
|
||||||
|
<NotesSection invoice={invoice} settings={settings} />
|
||||||
|
)}
|
||||||
<TotalsSection invoice={invoice} items={items} settings={settings} />
|
<TotalsSection invoice={invoice} items={items} settings={settings} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|
||||||
|
|||||||
+109
-104
@@ -3,123 +3,128 @@ import { invoices, clients } from "~/server/db/schema";
|
|||||||
import { eq, desc } from "drizzle-orm";
|
import { eq, desc } from "drizzle-orm";
|
||||||
|
|
||||||
export const dashboardRouter = createTRPCRouter({
|
export const dashboardRouter = createTRPCRouter({
|
||||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
|
||||||
// 1. Fetch all invoices for the user to calculate stats
|
// 1. Fetch all invoices for the user to calculate stats
|
||||||
// Note: For very large datasets, we should use separate count/sum queries,
|
// Note: For very large datasets, we should use separate count/sum queries,
|
||||||
// but for typical usage, fetching fields is fine and allows flexible JS calculation
|
// but for typical usage, fetching fields is fine and allows flexible JS calculation
|
||||||
// where SQL complexity might be high (e.g. dynamic status).
|
// where SQL complexity might be high (e.g. dynamic status).
|
||||||
// However, let's try to be efficient with SQL where possible.
|
// However, let's try to be efficient with SQL where possible.
|
||||||
|
|
||||||
const userInvoices = await ctx.db.query.invoices.findMany({
|
const userInvoices = await ctx.db.query.invoices.findMany({
|
||||||
where: eq(invoices.createdById, userId),
|
where: eq(invoices.createdById, userId),
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
totalAmount: true,
|
totalAmount: true,
|
||||||
status: true,
|
status: true,
|
||||||
dueDate: true,
|
dueDate: true,
|
||||||
issueDate: true,
|
issueDate: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const userClientsCount = await ctx.db.$count(
|
const userClientsCount = await ctx.db.$count(
|
||||||
clients,
|
clients,
|
||||||
eq(clients.createdById, userId),
|
eq(clients.createdById, userId),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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 "sent";
|
return "overdue";
|
||||||
};
|
return "sent";
|
||||||
|
};
|
||||||
|
|
||||||
// Calculate Stats
|
// Calculate Stats
|
||||||
let totalRevenue = 0;
|
let totalRevenue = 0;
|
||||||
let pendingAmount = 0;
|
let pendingAmount = 0;
|
||||||
let overdueCount = 0;
|
let overdueCount = 0;
|
||||||
|
|
||||||
let currentMonthRevenue = 0;
|
let currentMonthRevenue = 0;
|
||||||
let lastMonthRevenue = 0;
|
let lastMonthRevenue = 0;
|
||||||
|
|
||||||
for (const inv of userInvoices) {
|
for (const inv of userInvoices) {
|
||||||
const status = getStatus(inv);
|
const status = getStatus(inv);
|
||||||
const amount = inv.totalAmount;
|
const amount = inv.totalAmount;
|
||||||
const issueDate = new Date(inv.issueDate);
|
const issueDate = new Date(inv.issueDate);
|
||||||
|
|
||||||
if (status === "paid") {
|
if (status === "paid") {
|
||||||
totalRevenue += amount;
|
totalRevenue += amount;
|
||||||
|
|
||||||
if (issueDate >= currentMonthStart) {
|
if (issueDate >= currentMonthStart) {
|
||||||
currentMonthRevenue += amount;
|
currentMonthRevenue += amount;
|
||||||
} else if (issueDate >= lastMonthStart && issueDate < currentMonthStart) {
|
} else if (
|
||||||
lastMonthRevenue += amount;
|
issueDate >= lastMonthStart &&
|
||||||
}
|
issueDate < currentMonthStart
|
||||||
} else if (status === "sent" || status === "overdue") {
|
) {
|
||||||
pendingAmount += amount;
|
lastMonthRevenue += amount;
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "overdue") {
|
|
||||||
overdueCount++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if (status === "sent" || status === "overdue") {
|
||||||
|
pendingAmount += amount;
|
||||||
|
}
|
||||||
|
|
||||||
// Revenue Trend (Last 6 months)
|
if (status === "overdue") {
|
||||||
const revenueByMonth: Record<string, number> = {};
|
overdueCount++;
|
||||||
for (let i = 0; i < 6; i++) {
|
}
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
}
|
||||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
||||||
revenueByMonth[key] = 0;
|
// Revenue Trend (Last 6 months)
|
||||||
|
const revenueByMonth: Record<string, number> = {};
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
revenueByMonth[key] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const inv of userInvoices) {
|
||||||
|
if (getStatus(inv) === "paid") {
|
||||||
|
const d = new Date(inv.issueDate);
|
||||||
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
if (revenueByMonth[key] !== undefined) {
|
||||||
|
revenueByMonth[key] += inv.totalAmount;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const inv of userInvoices) {
|
const revenueChartData = Object.entries(revenueByMonth)
|
||||||
if (getStatus(inv) === "paid") {
|
.map(([month, revenue]) => ({
|
||||||
const d = new Date(inv.issueDate);
|
month,
|
||||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
revenue,
|
||||||
if (revenueByMonth[key] !== undefined) {
|
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
|
||||||
revenueByMonth[key] += inv.totalAmount;
|
month: "short",
|
||||||
}
|
year: "2-digit",
|
||||||
}
|
}),
|
||||||
}
|
}))
|
||||||
|
.sort((a, b) => a.month.localeCompare(b.month));
|
||||||
|
|
||||||
const revenueChartData = Object.entries(revenueByMonth)
|
// Recent Activity
|
||||||
.map(([month, revenue]) => ({
|
const recentInvoices = await ctx.db.query.invoices.findMany({
|
||||||
month,
|
where: eq(invoices.createdById, userId),
|
||||||
revenue,
|
orderBy: [desc(invoices.issueDate)],
|
||||||
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
|
limit: 5,
|
||||||
month: "short",
|
with: {
|
||||||
year: "2-digit",
|
client: {
|
||||||
}),
|
columns: { name: true },
|
||||||
}))
|
},
|
||||||
.sort((a, b) => a.month.localeCompare(b.month));
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Recent Activity
|
return {
|
||||||
const recentInvoices = await ctx.db.query.invoices.findMany({
|
totalRevenue,
|
||||||
where: eq(invoices.createdById, userId),
|
pendingAmount,
|
||||||
orderBy: [desc(invoices.issueDate)],
|
overdueCount,
|
||||||
limit: 5,
|
totalClients: userClientsCount,
|
||||||
with: {
|
revenueChange:
|
||||||
client: {
|
lastMonthRevenue > 0
|
||||||
columns: { name: true },
|
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
|
||||||
},
|
: 0,
|
||||||
},
|
revenueChartData,
|
||||||
});
|
recentInvoices,
|
||||||
|
};
|
||||||
return {
|
}),
|
||||||
totalRevenue,
|
|
||||||
pendingAmount,
|
|
||||||
overdueCount,
|
|
||||||
totalClients: userClientsCount,
|
|
||||||
revenueChange: lastMonthRevenue > 0
|
|
||||||
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
|
|
||||||
: 0,
|
|
||||||
revenueChartData,
|
|
||||||
recentInvoices,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -217,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,
|
||||||
@@ -234,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,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -253,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(),
|
||||||
@@ -289,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,
|
||||||
@@ -305,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,
|
||||||
@@ -425,13 +429,38 @@ 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 };
|
||||||
}),
|
}),
|
||||||
|
|||||||
+56
-56
@@ -1,65 +1,65 @@
|
|||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
|
|
||||||
type UmamiPayload = {
|
type UmamiPayload = {
|
||||||
payload: {
|
payload: {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
language: string;
|
language: string;
|
||||||
referrer: string;
|
referrer: string;
|
||||||
screen: string;
|
screen: string;
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
website: string;
|
website: string;
|
||||||
name: string;
|
name: string;
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
type: "event";
|
type: "event";
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function trackServerEvent(
|
export async function trackServerEvent(
|
||||||
eventName: string,
|
eventName: string,
|
||||||
eventData?: Record<string, unknown>,
|
eventData?: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
|
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
|
||||||
console.warn("Umami not configured, skipping server-side event tracking");
|
console.warn("Umami not configured, skipping server-side event tracking");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract API endpoint from script URL (e.g., https://analytics.umami.is/script.js -> https://analytics.umami.is/api/send)
|
// Extract API endpoint from script URL (e.g., https://analytics.umami.is/script.js -> https://analytics.umami.is/api/send)
|
||||||
const scriptUrl = new URL(env.NEXT_PUBLIC_UMAMI_SCRIPT_URL);
|
const scriptUrl = new URL(env.NEXT_PUBLIC_UMAMI_SCRIPT_URL);
|
||||||
const apiUrl = `${scriptUrl.origin}/api/send`;
|
const apiUrl = `${scriptUrl.origin}/api/send`;
|
||||||
|
|
||||||
const payload: UmamiPayload = {
|
const payload: UmamiPayload = {
|
||||||
payload: {
|
payload: {
|
||||||
hostname: env.NEXT_PUBLIC_APP_URL
|
hostname: env.NEXT_PUBLIC_APP_URL
|
||||||
? new URL(env.NEXT_PUBLIC_APP_URL).hostname
|
? new URL(env.NEXT_PUBLIC_APP_URL).hostname
|
||||||
: "localhost",
|
: "localhost",
|
||||||
language: "en-US",
|
language: "en-US",
|
||||||
referrer: "",
|
referrer: "",
|
||||||
screen: "",
|
screen: "",
|
||||||
title: "Server Event",
|
title: "Server Event",
|
||||||
url: "/",
|
url: "/",
|
||||||
website: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
website: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||||
name: eventName,
|
name: eventName,
|
||||||
data: eventData,
|
data: eventData,
|
||||||
},
|
},
|
||||||
type: "event",
|
type: "event",
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(apiUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"User-Agent":
|
"User-Agent":
|
||||||
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error("Failed to send Umami event:", await response.text());
|
console.error("Failed to send Umami event:", await response.text());
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error sending Umami event:", error);
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error sending Umami event:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user