mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 17:48:55 -04:00
Compare commits
24 Commits
1b6dfbb460
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e46fdafb2 | |||
| ddc2b42672 | |||
| dbb739b060 | |||
| bd3181fb9d | |||
| 915ec103fc | |||
| 4108019eab | |||
| 84a5d997b4 | |||
| ad89ad001d | |||
| 4fd6772f2e | |||
| fbeca7cfee | |||
| b582b6c88e | |||
| 00e066ca4e | |||
| 4214a4b4de | |||
| af392e1bc9 | |||
| 74f9696023 | |||
| 1f76cf38a7 | |||
| e5242b37a4 | |||
| 38206f34fe | |||
| e950abd805 | |||
| 4c0eae4b11 | |||
| e6b79ce2c2 | |||
| ba14526fc5 | |||
| 563d77ba65 | |||
| fb5ffc3195 |
+1
-3
@@ -6,14 +6,12 @@ Dockerfile*
|
|||||||
docker-compose*
|
docker-compose*
|
||||||
README.md
|
README.md
|
||||||
*.log
|
*.log
|
||||||
|
.DS_Store
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
drizzle/*.sql
|
|
||||||
drizzle/*-journal
|
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
coverage
|
coverage
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
|
|
||||||
|
|||||||
+43
-35
@@ -1,43 +1,51 @@
|
|||||||
# Base application env
|
# Copy this file to .env before running Docker Compose:
|
||||||
NODE_ENV="development"
|
# cp .env.example .env
|
||||||
PORT="3000"
|
|
||||||
HOSTNAME="0.0.0.0"
|
# Runtime
|
||||||
|
NODE_ENV=production
|
||||||
|
WEB_PORT=3000
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
# You can generate a new secret on the command line with:
|
# Generate with: openssl rand -base64 32
|
||||||
# openssl rand -base64 32
|
AUTH_SECRET=change-me-generate-a-real-secret
|
||||||
AUTH_SECRET="your-auth-secret"
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
BETTER_AUTH_URL="http://localhost:3000" # Set to your production URL in production
|
|
||||||
|
|
||||||
# App URL
|
# Public app URL
|
||||||
# Used for client-side redirects and base URLs
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
|
||||||
|
|
||||||
# Database (Postgres)
|
# Postgres used by docker-compose.yml
|
||||||
# These are required for Docker container initialization
|
POSTGRES_USER=postgres
|
||||||
POSTGRES_USER="postgres"
|
POSTGRES_PASSWORD=postgres
|
||||||
POSTGRES_PASSWORD="postgres"
|
POSTGRES_DB=postgres
|
||||||
POSTGRES_DB="postgres"
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
||||||
|
DB_DISABLE_SSL=true
|
||||||
|
|
||||||
# Connect string for the app
|
# White-label defaults used at image build time.
|
||||||
DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres"
|
# Admin-managed platform branding in the app can override these after setup.
|
||||||
# Disable SSL for Docker local Postgres; set to false or remove for managed Postgres
|
NEXT_PUBLIC_BRAND_NAME="beenvoice"
|
||||||
DB_DISABLE_SSL="true"
|
NEXT_PUBLIC_BRAND_TAGLINE="Simple and efficient invoicing for freelancers and small businesses"
|
||||||
|
NEXT_PUBLIC_BRAND_LOGO_TEXT="beenvoice"
|
||||||
|
NEXT_PUBLIC_BRAND_ICON="$"
|
||||||
|
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME="beenvoice"
|
||||||
|
NEXT_PUBLIC_DEFAULT_FONT="brand"
|
||||||
|
NEXT_PUBLIC_DEFAULT_BODY_FONT="brand"
|
||||||
|
NEXT_PUBLIC_DEFAULT_HEADING_FONT="brand"
|
||||||
|
NEXT_PUBLIC_DEFAULT_RADIUS="xl"
|
||||||
|
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE="floating"
|
||||||
|
|
||||||
# Email (Resend). Replace with real keys in production
|
# Email delivery via Resend (optional)
|
||||||
RESEND_API_KEY="your-resend-api-key"
|
# Leave blank to disable invoice/password-reset email delivery.
|
||||||
RESEND_DOMAIN=""
|
RESEND_API_KEY=
|
||||||
|
RESEND_DOMAIN=
|
||||||
|
|
||||||
# Analytics
|
# Analytics via Umami (optional)
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID="your-website-id-here"
|
# Leave website ID blank to disable analytics.
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL="https://analytics.umami.is/script.js"
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
# Build tweaks
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
|
||||||
# SKIP_ENV_VALIDATION=1
|
|
||||||
|
|
||||||
# SSO / Authentik (Optional - only needed if using SSO authentication)
|
# SSO via Authentik OIDC (optional)
|
||||||
# Configure these if you want to enable Single Sign-On with Authentik OIDC
|
NEXT_PUBLIC_AUTHENTIK_ENABLED=false
|
||||||
# The issuer should be your Authentik application's OAuth2 provider URL
|
AUTHENTIK_ISSUER=
|
||||||
# Example: https://auth.example.com/application/o/your-app-slug
|
AUTHENTIK_CLIENT_ID=
|
||||||
AUTHENTIK_ISSUER=""
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
AUTHENTIK_CLIENT_ID=""
|
AUTHENTIK_ORIGIN=
|
||||||
AUTHENTIK_CLIENT_SECRET=""
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ yarn-error.log*
|
|||||||
# local env files
|
# local env files
|
||||||
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
||||||
.env
|
.env
|
||||||
|
.env.prod
|
||||||
.env*.local
|
.env*.local
|
||||||
.env*.production
|
.env*.production
|
||||||
|
|
||||||
|
|||||||
+26
-48
@@ -1,58 +1,36 @@
|
|||||||
FROM oven/bun:1.2.19 as deps
|
# syntax=docker/dockerfile:1
|
||||||
WORKDIR /app
|
FROM oven/bun:1 AS base
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
# Install dependencies (only package manifests copied first for better caching)
|
FROM base AS install
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
# Install minimal toolchain for native devDependencies (e.g., better-sqlite3) during build
|
RUN bun install --frozen-lockfile
|
||||||
# Minimal toolchain (kept for safety, but we skip dev deps)
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
|
||||||
&& ln -sf /usr/bin/python3 /usr/bin/python \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
# Install all deps (including dev) for build tooling like @tailwindcss/postcss
|
|
||||||
RUN bun install --frozen-lockfile --verbose
|
|
||||||
|
|
||||||
FROM oven/bun:1.2.19 as builder
|
FROM base AS build
|
||||||
WORKDIR /app
|
COPY --from=install /usr/src/app/node_modules node_modules
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV SKIP_ENV_VALIDATION=1
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build Next.js app (no memory constraints)
|
ENV NODE_ENV=production \
|
||||||
RUN bun run build
|
SKIP_ENV_VALIDATION=1 \
|
||||||
|
NODE_OPTIONS=--max-old-space-size=4096 \
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000 \
|
||||||
|
AUTH_SECRET=docker-build-placeholder-secret-do-not-use \
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
||||||
|
RUN bun run build && bun build src/server/db/migrate.ts --target=bun --outfile=migrate.js
|
||||||
|
|
||||||
FROM oven/bun:1.2.19 as runner
|
FROM base AS release
|
||||||
WORKDIR /app
|
ENV NODE_ENV=production \
|
||||||
|
PORT=3000 \
|
||||||
|
HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
COPY --from=build /usr/src/app/.next/standalone ./
|
||||||
ENV PORT=3000
|
COPY --from=build /usr/src/app/.next/static ./.next/static
|
||||||
|
COPY --from=build /usr/src/app/public ./public
|
||||||
|
COPY --from=build /usr/src/app/migrate.js ./migrate.js
|
||||||
|
COPY --from=build /usr/src/app/drizzle ./drizzle
|
||||||
|
|
||||||
# Create non-root user and group
|
RUN chmod -R a+rX drizzle migrate.js public
|
||||||
RUN addgroup --system --gid 1001 beenvoice \
|
|
||||||
&& adduser --system --uid 1001 --ingroup beenvoice beenvoice
|
|
||||||
|
|
||||||
# Copy runtime artifacts and install production deps
|
|
||||||
COPY --from=builder /app/.next ./.next
|
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
COPY --from=builder /app/package.json ./package.json
|
|
||||||
COPY --from=builder /app/bun.lock ./bun.lock
|
|
||||||
RUN bun install --frozen-lockfile --production --verbose
|
|
||||||
COPY --from=builder /app/start.sh ./start.sh
|
|
||||||
COPY --from=builder /app/next.config.js ./next.config.js
|
|
||||||
COPY --from=builder /app/src ./src
|
|
||||||
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
|
|
||||||
COPY --from=builder /app/.env.example ./.env.example
|
|
||||||
|
|
||||||
RUN chmod +x ./start.sh
|
|
||||||
|
|
||||||
USER 1001
|
|
||||||
|
|
||||||
|
USER bun
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
CMD ["sh", "-c", "bun migrate.js && bun server.js"]
|
||||||
CMD ["./start.sh"]
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,23 +8,29 @@ A modern, professional invoicing application built for freelancers and small bus
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **🔐 Secure Authentication** - Email/password registration and sign-in with NextAuth.js
|
- **🔐 Secure Authentication** - Email/password registration and sign-in with better-auth, plus SSO via Authentik OIDC
|
||||||
- **👥 Client Management** - Create, edit, and manage client information
|
- **👥 Client Management** - Create, edit, and manage client information
|
||||||
|
- **🏢 Business Profiles** - Manage your business details, logo, and email settings
|
||||||
- **📄 Professional Invoices** - Generate detailed invoices with line items
|
- **📄 Professional Invoices** - Generate detailed invoices with line items
|
||||||
|
- **📅 Timesheet View** - Calendar-based time entry with month and week views
|
||||||
|
- **📧 Email Delivery** - Send invoices via email using Resend
|
||||||
|
- **📥 PDF Export** - Download invoices as professional PDFs
|
||||||
|
- **📊 CSV Import** - Bulk import invoice data from CSV files
|
||||||
- **💰 Flexible Pricing** - Set custom rates and calculate totals automatically
|
- **💰 Flexible Pricing** - Set custom rates and calculate totals automatically
|
||||||
- **📱 Responsive Design** - Works seamlessly on desktop, tablet, and mobile
|
- **📱 Responsive Design** - Works seamlessly on desktop, tablet, and mobile
|
||||||
- **🎨 Modern UI** - Clean, professional interface built with shadcn/ui
|
- **🎨 Modern UI** - Clean, professional interface built with shadcn/ui
|
||||||
- **⚡ Type-Safe** - Full TypeScript support with tRPC for API calls
|
- **⚡ Type-Safe** - Full TypeScript support with tRPC for API calls
|
||||||
- **💾 Local Database** - SQLite database with Drizzle ORM
|
- **💾 PostgreSQL Database** - Robust relational database with Drizzle ORM
|
||||||
|
|
||||||
## 🚀 Tech Stack
|
## 🚀 Tech Stack
|
||||||
|
|
||||||
- **Frontend**: Next.js 15 with App Router
|
- **Frontend**: Next.js 16 with App Router
|
||||||
- **Backend**: tRPC for type-safe API calls
|
- **Backend**: tRPC for type-safe API calls
|
||||||
- **Database**: Drizzle ORM with LibSQL (SQLite)
|
- **Database**: Drizzle ORM with PostgreSQL
|
||||||
- **Authentication**: NextAuth.js with email/password
|
- **Authentication**: better-auth with email/password and Authentik OIDC SSO
|
||||||
- **UI Components**: shadcn/ui with Tailwind CSS
|
- **UI Components**: shadcn/ui with Tailwind CSS v4
|
||||||
- **Styling**: Geist font family
|
- **Email**: Resend for transactional email delivery
|
||||||
|
- **PDF**: @react-pdf/renderer for invoice PDF generation
|
||||||
- **Package Manager**: Bun
|
- **Package Manager**: Bun
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
@@ -32,45 +38,69 @@ A modern, professional invoicing application built for freelancers and small bus
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 18+ or Bun
|
- Node.js 18+ or Bun
|
||||||
|
- Docker & Docker Compose (for local PostgreSQL)
|
||||||
- Git
|
- Git
|
||||||
|
|
||||||
### 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
|
```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_URL="file:./db.sqlite"
|
# Database
|
||||||
NEXTAUTH_SECRET="your-secret-key-here"
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice"
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
DB_DISABLE_SSL="true"
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
AUTH_SECRET="your-secret-key-here"
|
||||||
|
BETTER_AUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# Application
|
||||||
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
NODE_ENV="development"
|
||||||
|
|
||||||
|
# Email (optional for local dev)
|
||||||
|
RESEND_API_KEY="your-resend-api-key"
|
||||||
|
RESEND_DOMAIN="yourdomain.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Initialize the database**
|
4. **Start the development database**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up -d db
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Push the database schema**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run db:push
|
bun run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Start the development server**
|
6. **Start the development server**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **Open your browser**
|
7. **Open your browser**
|
||||||
Navigate to [http://localhost:3000](http://localhost:3000)
|
Navigate to [http://localhost:3000](http://localhost:3000)
|
||||||
|
|
||||||
## 🏗️ Project Structure
|
## 🏗️ Project Structure
|
||||||
@@ -79,21 +109,29 @@ A modern, professional invoicing application built for freelancers and small bus
|
|||||||
beenvoice/
|
beenvoice/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/ # Next.js App Router pages
|
│ ├── app/ # Next.js App Router pages
|
||||||
│ │ ├── api/ # API routes (NextAuth, tRPC)
|
│ │ ├── api/ # API routes (better-auth, tRPC)
|
||||||
│ │ ├── auth/ # Authentication pages
|
│ │ ├── auth/ # Authentication pages
|
||||||
│ │ ├── clients/ # Client management pages
|
│ │ ├── dashboard/ # Main app pages
|
||||||
│ │ ├── invoices/ # Invoice management pages
|
│ │ │ ├── clients/ # Client management pages
|
||||||
|
│ │ │ ├── invoices/ # Invoice management pages
|
||||||
|
│ │ │ └── businesses/ # Business profile pages
|
||||||
│ │ └── _components/ # Page-specific components
|
│ │ └── _components/ # Page-specific components
|
||||||
│ ├── components/ # Shared UI components
|
│ ├── components/ # Shared UI components
|
||||||
|
│ │ ├── ui/ # shadcn/ui components
|
||||||
|
│ │ ├── data/ # Data display components
|
||||||
|
│ │ ├── forms/ # Form components
|
||||||
|
│ │ └── layout/ # Layout components
|
||||||
│ ├── server/ # Server-side code
|
│ ├── server/ # Server-side code
|
||||||
│ │ ├── api/ # tRPC routers
|
│ │ ├── api/ # tRPC routers
|
||||||
│ │ ├── auth/ # NextAuth configuration
|
|
||||||
│ │ └── db/ # Database schema and connection
|
│ │ └── db/ # Database schema and connection
|
||||||
|
│ ├── lib/ # Utilities (auth, pdf export, etc.)
|
||||||
│ ├── styles/ # Global styles
|
│ ├── styles/ # Global styles
|
||||||
│ └── trpc/ # tRPC client configuration
|
│ └── trpc/ # tRPC client configuration
|
||||||
├── drizzle/ # Database migrations
|
├── drizzle/ # Database migrations
|
||||||
├── public/ # Static assets
|
├── public/ # Static assets
|
||||||
└── docs/ # Documentation
|
├── docs/ # Documentation
|
||||||
|
├── docker-compose.yml # Deployment compose stack
|
||||||
|
└── docker-compose.dev.yml # Development overrides with exposed PostgreSQL
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎯 Usage
|
## 🎯 Usage
|
||||||
@@ -103,41 +141,57 @@ beenvoice/
|
|||||||
1. **Register an Account**
|
1. **Register an Account**
|
||||||
- Visit the sign-up page
|
- Visit the sign-up page
|
||||||
- Enter your name, email, and password
|
- Enter your name, email, and password
|
||||||
- Verify your email (if configured)
|
|
||||||
|
|
||||||
2. **Add Your First Client**
|
2. **Set Up Your Business**
|
||||||
|
- Navigate to Business Settings
|
||||||
|
- Add your business name, contact info, and logo
|
||||||
|
- Configure email settings for invoice delivery (Resend API key + domain)
|
||||||
|
|
||||||
|
3. **Add Your First Client**
|
||||||
- Navigate to the Clients page
|
- Navigate to the Clients page
|
||||||
- Click "Add New Client"
|
- Click "Add New Client"
|
||||||
- Fill in client details (name, email, phone, address)
|
- Fill in client details (name, email, phone, address)
|
||||||
|
|
||||||
3. **Create an Invoice**
|
4. **Create an Invoice**
|
||||||
- Go to the Invoices page
|
- Go to the Invoices page
|
||||||
- Click "Create New Invoice"
|
- Click "Create New Invoice"
|
||||||
- Select a client
|
- Select a client and optionally a business profile
|
||||||
- Add line items with descriptions, dates, hours, and rates
|
- Add line items with descriptions, dates, hours, and rates
|
||||||
- Save and generate your invoice
|
- Use the Timesheet tab for calendar-based time entry
|
||||||
|
- Save and send or download as PDF
|
||||||
|
|
||||||
### 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
|
||||||
- Search and filter client list
|
- Search and filter client list
|
||||||
- View client history
|
|
||||||
|
|
||||||
#### Invoice Creation
|
#### Invoice Creation
|
||||||
- Select from existing clients
|
|
||||||
- Add multiple line items
|
- Select from existing clients and business profiles
|
||||||
|
- Add multiple line items with drag-and-drop reordering
|
||||||
- Set custom rates per item
|
- Set custom rates per item
|
||||||
- Automatic total calculations
|
- Automatic total calculations with configurable tax rate
|
||||||
|
- Timesheet calendar view for date-based time tracking
|
||||||
- Professional invoice formatting
|
- Professional invoice formatting
|
||||||
|
|
||||||
|
#### Invoice Delivery
|
||||||
|
|
||||||
|
- Send invoices via email directly from the app
|
||||||
|
- Rich text email composer with preview
|
||||||
|
- Resend and re-deliver sent invoices
|
||||||
|
- Track invoice status: Draft → Sent → Paid (+ Overdue)
|
||||||
|
|
||||||
#### User Interface
|
#### User Interface
|
||||||
|
|
||||||
- Clean, modern design
|
- Clean, modern design
|
||||||
- Responsive layout
|
- Fully responsive — desktop, tablet, and mobile
|
||||||
- Intuitive navigation
|
- Intuitive navigation with breadcrumbs
|
||||||
- Toast notifications for feedback
|
- Toast notifications for feedback
|
||||||
- Modal dialogs for forms
|
- Dark mode support
|
||||||
|
|
||||||
## 🔧 Development
|
## 🔧 Development
|
||||||
|
|
||||||
@@ -145,44 +199,72 @@ beenvoice/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
# Development
|
||||||
bun run dev # Start development server
|
bun run dev # Start development server (Turbo)
|
||||||
bun run build # Build for production
|
bun run build # Build for production
|
||||||
bun run start # Start production server
|
bun run start # Start production server
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
bun run db:push # Push schema changes to database
|
bun run db:push # Push schema changes to database
|
||||||
|
bun run db:migrate # Run migrations
|
||||||
bun run db:studio # Open Drizzle Studio
|
bun run db:studio # Open Drizzle Studio
|
||||||
bun run db:generate # Generate new migration
|
bun run db:generate # Generate new migration
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
# Code Quality
|
# Code Quality
|
||||||
bun run lint # Run ESLint
|
bun run lint # Run ESLint
|
||||||
bun run format # Format code with Prettier
|
bun run lint:fix # Fix ESLint issues
|
||||||
bun run type-check # Run TypeScript type checking
|
bun run format:write # Format code with Prettier
|
||||||
|
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 four main tables:
|
The application uses the following core tables:
|
||||||
|
|
||||||
- **users**: User accounts and authentication
|
- **users** - User accounts and authentication
|
||||||
- **clients**: Client information and contact details
|
- **sessions** - Active user sessions
|
||||||
- **invoices**: Invoice headers with client relationships
|
- **clients** - Client information and contact details
|
||||||
- **invoice_items**: Individual line items with pricing
|
- **businesses** - Business profiles with email/logo settings
|
||||||
|
- **invoices** - Invoice headers with client and business relationships
|
||||||
|
- **invoice_items** - Individual line items with pricing and position ordering
|
||||||
|
|
||||||
### API Development
|
### API Development
|
||||||
|
|
||||||
All API endpoints are built with tRPC for type safety:
|
All API endpoints are built with tRPC for type safety:
|
||||||
|
|
||||||
- **Authentication**: NextAuth.js integration
|
- **Authentication**: better-auth integration (email/password + OIDC)
|
||||||
- **Clients**: CRUD operations for client management
|
- **Clients**: CRUD operations for client management
|
||||||
- **Invoices**: Invoice creation and management
|
- **Businesses**: Business profile management
|
||||||
|
- **Invoices**: Invoice creation, management, and status tracking
|
||||||
- **Validation**: Zod schemas for input validation
|
- **Validation**: Zod schemas for input validation
|
||||||
|
|
||||||
## 🎨 Customization
|
## 🎨 Customization
|
||||||
|
|
||||||
### Styling
|
### Styling
|
||||||
|
|
||||||
The app uses Tailwind CSS with a custom design system:
|
The app uses Tailwind CSS v4 with a custom design system:
|
||||||
|
|
||||||
- **Primary Color**: Green (#16a34a)
|
- **Primary Color**: Green (#16a34a)
|
||||||
- **Font**: Geist for professional typography
|
- **Font**: Geist for professional typography
|
||||||
@@ -192,44 +274,63 @@ The app uses Tailwind CSS 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
|
||||||
|
|
||||||
## 🚀 Deployment
|
## 🚀 Deployment
|
||||||
|
|
||||||
### Deployment
|
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 (Docker, Coolify, Railway, etc.).
|
1. **Build the application:**
|
||||||
|
|
||||||
1. Build the application:
|
```bash
|
||||||
```bash
|
bun run build
|
||||||
bun run build
|
```
|
||||||
```
|
|
||||||
|
|
||||||
2. Start the server:
|
2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production)
|
||||||
```bash
|
|
||||||
bun start
|
|
||||||
```
|
|
||||||
|
|
||||||
### Other Platforms
|
3. **Run database migrations:**
|
||||||
|
|
||||||
The app can be deployed to any platform that supports Next.js:
|
```bash
|
||||||
|
bun run db:push
|
||||||
|
```
|
||||||
|
|
||||||
- **Netlify**: Use the Next.js build command
|
4. **Start the server:**
|
||||||
- **Railway**: Connect your GitHub repository
|
```bash
|
||||||
- **DigitalOcean App Platform**: Deploy with automatic scaling
|
bun start
|
||||||
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
Required for production:
|
Required for production:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL="your-database-url"
|
DATABASE_URL="postgresql://user:password@host:5432/dbname"
|
||||||
NEXTAUTH_SECRET="your-secret-key"
|
AUTH_SECRET="your-long-random-secret"
|
||||||
NEXTAUTH_URL="https://your-domain.com"
|
BETTER_AUTH_URL="https://your-domain.com"
|
||||||
|
NEXT_PUBLIC_APP_URL="https://your-domain.com"
|
||||||
|
NODE_ENV="production"
|
||||||
|
|
||||||
|
# Email (required for invoice sending)
|
||||||
|
RESEND_API_KEY="re_xxxxxxxxxxxx"
|
||||||
|
RESEND_DOMAIN="yourdomain.com"
|
||||||
|
|
||||||
|
# Optional: Authentik SSO
|
||||||
|
AUTHENTIK_ISSUER="https://your-authentik-instance/application/o/beenvoice/"
|
||||||
|
AUTHENTIK_CLIENT_ID="your-client-id"
|
||||||
|
AUTHENTIK_CLIENT_SECRET="your-client-secret"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Other Platforms
|
||||||
|
|
||||||
|
The app can be deployed to any platform that supports Next.js:
|
||||||
|
|
||||||
|
- **Coolify**: Deploy with Docker Compose support
|
||||||
|
- **Railway**: Connect your GitHub repository (includes managed PostgreSQL)
|
||||||
|
- **DigitalOcean App Platform**: Deploy with automatic scaling
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
@@ -243,8 +344,7 @@ NEXTAUTH_URL="https://your-domain.com"
|
|||||||
- Follow TypeScript best practices
|
- Follow TypeScript best practices
|
||||||
- Use shadcn/ui components for consistency
|
- Use shadcn/ui components for consistency
|
||||||
- Implement proper error handling
|
- Implement proper error handling
|
||||||
- Add tests for new features
|
- Follow the existing code style (Prettier + ESLint configs provided)
|
||||||
- Follow the existing code style
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
@@ -254,14 +354,14 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|||||||
|
|
||||||
- [T3 Stack](https://create.t3.gg/) for the excellent development stack
|
- [T3 Stack](https://create.t3.gg/) for the excellent development stack
|
||||||
- [shadcn/ui](https://ui.shadcn.com/) for beautiful UI components
|
- [shadcn/ui](https://ui.shadcn.com/) for beautiful UI components
|
||||||
- [NextAuth.js](https://next-auth.js.org/) for authentication
|
- [better-auth](https://www.better-auth.com/) for modern authentication
|
||||||
- [Drizzle ORM](https://orm.drizzle.team/) for database management
|
- [Drizzle ORM](https://orm.drizzle.team/) for database management
|
||||||
|
- [Resend](https://resend.com/) for reliable email delivery
|
||||||
|
|
||||||
## 📞 Support
|
## 📞 Support
|
||||||
|
|
||||||
- **Issues**: [GitHub Issues](https://github.com/yourusername/beenvoice/issues)
|
- **Issues**: [GitHub Issues](https://github.com/yourusername/beenvoice/issues)
|
||||||
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/beenvoice/discussions)
|
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/beenvoice/discussions)
|
||||||
- **Email**: support@beenvoice.com
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
+35
-9
@@ -1,21 +1,47 @@
|
|||||||
services:
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
image: beenvoice:local
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
|
||||||
|
DB_DISABLE_SSL: "true"
|
||||||
|
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||||
|
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
|
||||||
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
|
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
|
||||||
|
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_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
|
||||||
|
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false}
|
||||||
|
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
|
||||||
|
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
|
||||||
|
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
|
||||||
|
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-3000}:3000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
container_name: beenvoice-db
|
environment:
|
||||||
env_file:
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
- .env.local
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
||||||
volumes:
|
volumes:
|
||||||
- beenvoice_pg_data:/var/lib/postgresql/data
|
- beenvoice_pg_data:/var/lib/postgresql/data
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test:
|
||||||
|
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
beenvoice_pg_data:
|
beenvoice_pg_data:
|
||||||
driver: local
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ if (!process.env.DATABASE_URL) {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
schema: "./src/server/db/schema.ts",
|
schema: "./src/server/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL,
|
url: process.env.DATABASE_URL,
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
CREATE TABLE "beenvoice_account" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"userId" varchar(255) NOT NULL,
|
||||||
|
"accountId" varchar(255) NOT NULL,
|
||||||
|
"providerId" varchar(255) NOT NULL,
|
||||||
|
"accessToken" text,
|
||||||
|
"refreshToken" text,
|
||||||
|
"accessTokenExpiresAt" timestamp,
|
||||||
|
"refreshTokenExpiresAt" timestamp,
|
||||||
|
"scope" varchar(255),
|
||||||
|
"idToken" text,
|
||||||
|
"password" text,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_business" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"nickname" varchar(255),
|
||||||
|
"email" varchar(255),
|
||||||
|
"phone" varchar(50),
|
||||||
|
"addressLine1" varchar(255),
|
||||||
|
"addressLine2" varchar(255),
|
||||||
|
"city" varchar(100),
|
||||||
|
"state" varchar(50),
|
||||||
|
"postalCode" varchar(20),
|
||||||
|
"country" varchar(100),
|
||||||
|
"website" varchar(255),
|
||||||
|
"taxId" varchar(100),
|
||||||
|
"logoUrl" varchar(500),
|
||||||
|
"isDefault" boolean DEFAULT false,
|
||||||
|
"resendApiKey" varchar(255),
|
||||||
|
"resendDomain" varchar(255),
|
||||||
|
"emailFromName" varchar(255),
|
||||||
|
"createdById" varchar(255) NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updatedAt" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_client" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"email" varchar(255),
|
||||||
|
"phone" varchar(50),
|
||||||
|
"addressLine1" varchar(255),
|
||||||
|
"addressLine2" varchar(255),
|
||||||
|
"city" varchar(100),
|
||||||
|
"state" varchar(50),
|
||||||
|
"postalCode" varchar(20),
|
||||||
|
"country" varchar(100),
|
||||||
|
"defaultHourlyRate" real,
|
||||||
|
"createdById" varchar(255) NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updatedAt" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_invoice_item" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"invoiceId" varchar(255) NOT NULL,
|
||||||
|
"date" timestamp NOT NULL,
|
||||||
|
"description" varchar(500) NOT NULL,
|
||||||
|
"hours" real NOT NULL,
|
||||||
|
"rate" real NOT NULL,
|
||||||
|
"amount" real NOT NULL,
|
||||||
|
"position" integer DEFAULT 0 NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_invoice" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"invoiceNumber" varchar(100) NOT NULL,
|
||||||
|
"businessId" varchar(255),
|
||||||
|
"clientId" varchar(255) NOT NULL,
|
||||||
|
"issueDate" timestamp NOT NULL,
|
||||||
|
"dueDate" timestamp NOT NULL,
|
||||||
|
"status" varchar(50) DEFAULT 'draft' NOT NULL,
|
||||||
|
"totalAmount" real DEFAULT 0 NOT NULL,
|
||||||
|
"taxRate" real DEFAULT 0 NOT NULL,
|
||||||
|
"notes" varchar(1000),
|
||||||
|
"createdById" varchar(255) NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updatedAt" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_session" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"userId" varchar(255) NOT NULL,
|
||||||
|
"token" varchar(255) NOT NULL,
|
||||||
|
"expiresAt" timestamp NOT NULL,
|
||||||
|
"ipAddress" text,
|
||||||
|
"userAgent" text,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "beenvoice_session_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_sso_provider" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"providerId" varchar(255) NOT NULL,
|
||||||
|
"userId" varchar(255) NOT NULL,
|
||||||
|
"redirectURI" varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
"oidcConfig" text,
|
||||||
|
"samlConfig" text,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "beenvoice_sso_provider_providerId_unique" UNIQUE("providerId")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_user" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"email" varchar(255) NOT NULL,
|
||||||
|
"emailVerified" boolean DEFAULT false NOT NULL,
|
||||||
|
"image" varchar(255),
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"password" varchar(255),
|
||||||
|
"resetToken" varchar(255),
|
||||||
|
"resetTokenExpiry" timestamp,
|
||||||
|
"prefersReducedMotion" boolean DEFAULT false NOT NULL,
|
||||||
|
"animationSpeedMultiplier" real DEFAULT 1 NOT NULL,
|
||||||
|
"colorTheme" varchar(50) DEFAULT 'slate' NOT NULL,
|
||||||
|
"customColor" varchar(50),
|
||||||
|
"theme" varchar(20) DEFAULT 'system' NOT NULL,
|
||||||
|
CONSTRAINT "beenvoice_user_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_verification_token" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"identifier" varchar(255) NOT NULL,
|
||||||
|
"value" varchar(255) NOT NULL,
|
||||||
|
"expiresAt" timestamp NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_account" ADD CONSTRAINT "beenvoice_account_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_business" ADD CONSTRAINT "beenvoice_business_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_client" ADD CONSTRAINT "beenvoice_client_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_businessId_beenvoice_business_id_fk" FOREIGN KEY ("businessId") REFERENCES "public"."beenvoice_business"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_session" ADD CONSTRAINT "beenvoice_session_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_sso_provider" ADD CONSTRAINT "beenvoice_sso_provider_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "account_userId_idx" ON "beenvoice_account" USING btree ("userId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "business_created_by_idx" ON "beenvoice_business" USING btree ("createdById");--> statement-breakpoint
|
||||||
|
CREATE INDEX "business_name_idx" ON "beenvoice_business" USING btree ("name");--> statement-breakpoint
|
||||||
|
CREATE INDEX "business_nickname_idx" ON "beenvoice_business" USING btree ("nickname");--> statement-breakpoint
|
||||||
|
CREATE INDEX "business_email_idx" ON "beenvoice_business" USING btree ("email");--> statement-breakpoint
|
||||||
|
CREATE INDEX "business_is_default_idx" ON "beenvoice_business" USING btree ("isDefault");--> statement-breakpoint
|
||||||
|
CREATE INDEX "client_created_by_idx" ON "beenvoice_client" USING btree ("createdById");--> statement-breakpoint
|
||||||
|
CREATE INDEX "client_name_idx" ON "beenvoice_client" USING btree ("name");--> statement-breakpoint
|
||||||
|
CREATE INDEX "client_email_idx" ON "beenvoice_client" USING btree ("email");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_item_invoice_id_idx" ON "beenvoice_invoice_item" USING btree ("invoiceId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_item_date_idx" ON "beenvoice_invoice_item" USING btree ("date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_item_position_idx" ON "beenvoice_invoice_item" USING btree ("position");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_business_id_idx" ON "beenvoice_invoice" USING btree ("businessId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_client_id_idx" ON "beenvoice_invoice" USING btree ("clientId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_created_by_idx" ON "beenvoice_invoice" USING btree ("createdById");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_number_idx" ON "beenvoice_invoice" USING btree ("invoiceNumber");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_status_idx" ON "beenvoice_invoice" USING btree ("status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "session_userId_idx" ON "beenvoice_session" USING btree ("userId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sso_provider_user_id_idx" ON "beenvoice_sso_provider" USING btree ("userId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "verification_token_identifier_idx" ON "beenvoice_verification_token" USING btree ("identifier");
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
CREATE TABLE "beenvoice_expense" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"businessId" varchar(255),
|
||||||
|
"clientId" varchar(255),
|
||||||
|
"invoiceId" varchar(255),
|
||||||
|
"date" timestamp NOT NULL,
|
||||||
|
"description" varchar(500) NOT NULL,
|
||||||
|
"amount" real NOT NULL,
|
||||||
|
"currency" varchar(3) DEFAULT 'USD' NOT NULL,
|
||||||
|
"category" varchar(100),
|
||||||
|
"billable" boolean DEFAULT false NOT NULL,
|
||||||
|
"reimbursable" boolean DEFAULT false NOT NULL,
|
||||||
|
"notes" varchar(500),
|
||||||
|
"createdById" varchar(255) NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updatedAt" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "beenvoice_invoice_template" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"type" varchar(50) DEFAULT 'notes' NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"isDefault" boolean DEFAULT false NOT NULL,
|
||||||
|
"createdById" varchar(255) NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updatedAt" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_client" ADD COLUMN "currency" varchar(3) DEFAULT 'USD' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice" ADD COLUMN "currency" varchar(3) DEFAULT 'USD' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_businessId_beenvoice_business_id_fk" FOREIGN KEY ("businessId") REFERENCES "public"."beenvoice_business"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_invoice_template" ADD CONSTRAINT "beenvoice_invoice_template_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "expense_created_by_idx" ON "beenvoice_expense" USING btree ("createdById");--> statement-breakpoint
|
||||||
|
CREATE INDEX "expense_client_id_idx" ON "beenvoice_expense" USING btree ("clientId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "expense_invoice_id_idx" ON "beenvoice_expense" USING btree ("invoiceId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "expense_date_idx" ON "beenvoice_expense" USING btree ("date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "expense_billable_idx" ON "beenvoice_expense" USING btree ("billable");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_template_created_by_idx" ON "beenvoice_invoice_template" USING btree ("createdById");--> statement-breakpoint
|
||||||
|
CREATE INDEX "invoice_template_type_idx" ON "beenvoice_invoice_template" USING btree ("type");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "beenvoice_expense" ADD COLUMN "taxDeductible" boolean DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "beenvoice_user" ADD COLUMN "interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL;
|
||||||
|
ALTER TABLE "beenvoice_user" ADD COLUMN "fontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "role" varchar(20) DEFAULT 'user' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "beenvoice_user"
|
||||||
|
SET "role" = 'admin'
|
||||||
|
WHERE "id" = (
|
||||||
|
SELECT "id"
|
||||||
|
FROM "beenvoice_user"
|
||||||
|
ORDER BY "createdAt" ASC
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "beenvoice_platform_setting" (
|
||||||
|
"id" varchar(50) PRIMARY KEY DEFAULT 'global' NOT NULL,
|
||||||
|
"brandName" varchar(100) DEFAULT 'beenvoice' NOT NULL,
|
||||||
|
"brandTagline" varchar(255) DEFAULT 'Simple and efficient invoicing for freelancers and small businesses' NOT NULL,
|
||||||
|
"brandLogoText" varchar(100) DEFAULT 'beenvoice' NOT NULL,
|
||||||
|
"brandIcon" varchar(20) DEFAULT '$' NOT NULL,
|
||||||
|
"colorTheme" varchar(50) DEFAULT 'slate' NOT NULL,
|
||||||
|
"customColor" varchar(50),
|
||||||
|
"theme" varchar(20) DEFAULT 'system' NOT NULL,
|
||||||
|
"interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL,
|
||||||
|
"bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
|
||||||
|
"headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
|
||||||
|
"radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL,
|
||||||
|
"sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "beenvoice_platform_setting" (
|
||||||
|
"id",
|
||||||
|
"brandName",
|
||||||
|
"brandTagline",
|
||||||
|
"brandLogoText",
|
||||||
|
"brandIcon",
|
||||||
|
"colorTheme",
|
||||||
|
"customColor",
|
||||||
|
"theme",
|
||||||
|
"interfaceTheme",
|
||||||
|
"bodyFontPreference",
|
||||||
|
"headingFontPreference",
|
||||||
|
"radiusPreference",
|
||||||
|
"sidebarStyle"
|
||||||
|
) VALUES (
|
||||||
|
'global',
|
||||||
|
'beenvoice',
|
||||||
|
'Simple and efficient invoicing for freelancers and small businesses',
|
||||||
|
'beenvoice',
|
||||||
|
'$',
|
||||||
|
'slate',
|
||||||
|
NULL,
|
||||||
|
'system',
|
||||||
|
'beenvoice',
|
||||||
|
'brand',
|
||||||
|
'brand',
|
||||||
|
'xl',
|
||||||
|
'floating'
|
||||||
|
) ON CONFLICT ("id") DO NOTHING;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfTemplate" varchar(20) DEFAULT 'classic' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfAccentColor" varchar(50) DEFAULT '#111827' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfFooterText" varchar(120) DEFAULT 'Professional Invoicing' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfShowLogo" boolean DEFAULT true NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfShowPageNumbers" boolean DEFAULT true NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "beenvoice_invoice"
|
||||||
|
ADD COLUMN "emailMessage" varchar(2000);
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775354242672,
|
||||||
|
"tag": "0000_glossy_magneto",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775356013998,
|
||||||
|
"tag": "0001_supreme_the_enforcers",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775400000000,
|
||||||
|
"tag": "0002_tax_deductible",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775600000000,
|
||||||
|
"tag": "0003_appearance_preferences",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777336000000,
|
||||||
|
"tag": "0004_platform_appearance_controls",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777337000000,
|
||||||
|
"tag": "0005_platform_settings_and_roles",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777338000000,
|
||||||
|
"tag": "0006_pdf_generation_settings",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777339000000,
|
||||||
|
"tag": "0007_invoice_email_message",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+3
-1
@@ -6,7 +6,9 @@ import "./src/env.js";
|
|||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
serverExternalPackages: ['pg'],
|
output: "standalone",
|
||||||
|
reactCompiler: true,
|
||||||
|
serverExternalPackages: ["pg"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
+14
-10
@@ -7,12 +7,13 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"check": "eslint . && tsc --noEmit",
|
"check": "eslint . && tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "bun drizzle-kit migrate",
|
||||||
"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",
|
||||||
@@ -64,15 +66,17 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
"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.1.1",
|
"next": "^16.2.4",
|
||||||
"pg": "8.13.1",
|
"pg": "8.13.1",
|
||||||
"react": "^19.2.3",
|
"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.3",
|
"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",
|
||||||
@@ -89,13 +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",
|
||||||
"dotenv": "^17.2.3",
|
"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
|
||||||
|
|||||||
@@ -55,6 +55,20 @@ export async function POST(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, user.id));
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
if (!env.RESEND_API_KEY) {
|
||||||
|
console.warn(
|
||||||
|
"Password reset requested, but RESEND_API_KEY is not configured.",
|
||||||
|
);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"If an account with that email exists, password reset instructions have been sent.",
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Send password reset email using Resend
|
// Send password reset email using Resend
|
||||||
try {
|
try {
|
||||||
const resend = new Resend(env.RESEND_API_KEY);
|
const resend = new Resend(env.RESEND_API_KEY);
|
||||||
|
|||||||
@@ -2,59 +2,97 @@ import bcrypt from "bcryptjs";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { env } from "~/env";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { users } from "~/server/db/schema";
|
import { accounts, users } from "~/server/db/schema";
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
firstName: z.string().min(1, "First name is required"),
|
firstName: z.string().trim().min(1, "First name is required"),
|
||||||
lastName: z.string().min(1, "Last name is required"),
|
lastName: z.string().trim().min(1, "Last name is required"),
|
||||||
email: z.string().email("Invalid email address"),
|
email: z.string().email("Invalid email address"),
|
||||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fieldLabels: Record<string, string> = {
|
||||||
|
firstName: "First name",
|
||||||
|
lastName: "Last name",
|
||||||
|
email: "Email address",
|
||||||
|
password: "Password",
|
||||||
|
};
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json() as z.infer<typeof registerSchema>;
|
if (env.DISABLE_SIGNUPS === true) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "New account registration is currently disabled" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as unknown;
|
||||||
const { firstName, lastName, email, password } = registerSchema.parse(body);
|
const { firstName, lastName, email, password } = registerSchema.parse(body);
|
||||||
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
// Check if user already exists
|
// Check if user already exists
|
||||||
const existingUser = await db.query.users.findFirst({
|
const existingUser = await db.query.users.findFirst({
|
||||||
where: eq(users.email, email),
|
where: eq(users.email, normalizedEmail),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "User with this email already exists" },
|
{ error: "User with this email already exists" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash password
|
// Hash password
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
// Create user
|
await db.transaction(async (tx) => {
|
||||||
await db.insert(users).values({
|
const [user] = await tx
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
name: `${firstName} ${lastName}`,
|
name: `${firstName} ${lastName}`,
|
||||||
email,
|
email: normalizedEmail,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
|
})
|
||||||
|
.returning({ id: users.id });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Failed to create user");
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(accounts).values({
|
||||||
|
userId: user.id,
|
||||||
|
accountId: user.id,
|
||||||
|
providerId: "credential",
|
||||||
|
password: hashedPassword,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: "User created successfully" },
|
{ message: "User created successfully" },
|
||||||
{ status: 201 }
|
{ status: 201 },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
|
const issue = error.errors[0];
|
||||||
|
const field = issue?.path[0];
|
||||||
|
const fallback =
|
||||||
|
typeof field === "string"
|
||||||
|
? `${fieldLabels[field] ?? field} is required`
|
||||||
|
: "Please check the registration form";
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.errors[0]?.message ?? "Validation error" },
|
{ error: issue?.message === "Required" ? fallback : issue?.message },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("Registration error:", error);
|
console.error("Registration error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal server error" },
|
{ error: "Internal server error" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server";
|
|||||||
import { eq, and, gt } from "drizzle-orm";
|
import { eq, and, gt } from "drizzle-orm";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { users } from "~/server/db/schema";
|
import { accounts, users } from "~/server/db/schema";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -47,8 +47,8 @@ export async function POST(request: NextRequest) {
|
|||||||
// Hash the new password
|
// Hash the new password
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
// Update user with new password and clear reset token
|
await db.transaction(async (tx) => {
|
||||||
await db
|
await tx
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
@@ -57,6 +57,31 @@ export async function POST(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, user.id));
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
const credentialAccount = await tx.query.accounts.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(accounts.userId, user.id),
|
||||||
|
eq(accounts.providerId, "credential"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (credentialAccount) {
|
||||||
|
await tx
|
||||||
|
.update(accounts)
|
||||||
|
.set({
|
||||||
|
password: hashedPassword,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(accounts.id, credentialAccount.id));
|
||||||
|
} else {
|
||||||
|
await tx.insert(accounts).values({
|
||||||
|
userId: user.id,
|
||||||
|
accountId: user.id,
|
||||||
|
providerId: "credential",
|
||||||
|
password: hashedPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -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,277 +1,11 @@
|
|||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
import { env } from "~/env";
|
||||||
import { useState, Suspense } from "react";
|
import { SignInForm } from "./signin-form";
|
||||||
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 {
|
|
||||||
Mail,
|
|
||||||
Lock,
|
|
||||||
ArrowRight,
|
|
||||||
Users,
|
|
||||||
FileText,
|
|
||||||
TrendingUp,
|
|
||||||
Shield,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
function SignInForm() {
|
|
||||||
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="flex min-h-screen items-center justify-center relative overflow-hidden">
|
|
||||||
{/* Blob Background */}
|
|
||||||
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
|
|
||||||
<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="w-[800px] h-[800px] bg-neutral-400/30 dark:bg-neutral-500/20 rounded-full blur-3xl animate-blob"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:border md:shadow-2xl md:bg-background/80 md:backdrop-blur-xl md:border-border/50 md:rounded-3xl">
|
|
||||||
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
|
||||||
{/* Hero Section - Hidden on mobile */}
|
|
||||||
<div className="bg-primary/5 relative hidden md:flex md:flex-col md:justify-center md:p-12 border-r border-border/50">
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Logo size="xl" />
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h1 className="text-3xl font-bold lg:text-4xl font-heading">
|
|
||||||
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="font-semibold text-foreground">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="font-semibold text-foreground">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="font-semibold text-foreground">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="text-3xl font-bold font-heading">Sign In</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Enter your credentials to access your account
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
className="w-full h-11 relative 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="w-full border-t border-border/50" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-background px-2 text-muted-foreground">
|
|
||||||
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="h-11 pl-10 bg-background/50 border-border/60 focus:bg-background 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="h-11 pl-10 bg-background/50 border-border/60 focus:bg-background transition-all"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="h-11 w-full rounded-xl text-base shadow-lg shadow-primary/20 hover:shadow-primary/30"
|
|
||||||
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;
|
||||||
@@ -86,12 +84,16 @@ export function RevenueChart({ data }: RevenueChartProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-64 w-full">
|
<div className="h-48 w-full md:h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={chartData}>
|
<AreaChart data={chartData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0.4} />
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor="hsl(217, 91%, 60%)"
|
||||||
|
stopOpacity={0.4}
|
||||||
|
/>
|
||||||
<stop
|
<stop
|
||||||
offset="95%"
|
offset="95%"
|
||||||
stopColor="hsl(217, 91%, 60%)"
|
stopColor="hsl(217, 91%, 60%)"
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Shield } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
export function AdministrationContent() {
|
||||||
|
const {
|
||||||
|
data: accounts = [],
|
||||||
|
refetch,
|
||||||
|
error,
|
||||||
|
} = api.settings.listAccounts.useQuery();
|
||||||
|
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Account role updated");
|
||||||
|
void refetch();
|
||||||
|
},
|
||||||
|
onError: (mutationError: { message: string }) => {
|
||||||
|
toast.error(`Failed to update role: ${mutationError.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-card border-border border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground flex items-center gap-2">
|
||||||
|
<Shield className="text-primary h-5 w-5" />
|
||||||
|
Administration
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Administrative access is required for this page.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-card border-border border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground flex items-center gap-2">
|
||||||
|
<Shield className="text-primary h-5 w-5" />
|
||||||
|
Accounts
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage account access and roles without opening customer data.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<div
|
||||||
|
key={account.id}
|
||||||
|
className="border-border flex flex-col gap-3 border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium">{account.name}</p>
|
||||||
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
|
{account.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Created {new Date(account.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={account.role}
|
||||||
|
onValueChange={(role) =>
|
||||||
|
updateAccountRoleMutation.mutate({
|
||||||
|
userId: account.id,
|
||||||
|
role: role as "user" | "admin",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
|
||||||
|
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||||
|
|
||||||
|
interface ExpenseFormData {
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
category: string;
|
||||||
|
billable: boolean;
|
||||||
|
reimbursable: boolean;
|
||||||
|
taxDeductible: boolean;
|
||||||
|
notes: string;
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultForm: ExpenseFormData = {
|
||||||
|
date: new Date(),
|
||||||
|
description: "",
|
||||||
|
amount: 0,
|
||||||
|
currency: "USD",
|
||||||
|
category: "",
|
||||||
|
billable: false,
|
||||||
|
reimbursable: false,
|
||||||
|
taxDeductible: false,
|
||||||
|
notes: "",
|
||||||
|
clientId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ExpensesPage() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data: expenses = [], isLoading } = api.expenses.getAll.useQuery();
|
||||||
|
const { data: clients = [] } = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
const create = api.expenses.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Expense added");
|
||||||
|
void utils.expenses.getAll.invalidate();
|
||||||
|
setOpen(false);
|
||||||
|
setForm(defaultForm);
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
const update = api.expenses.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Expense updated");
|
||||||
|
void utils.expenses.getAll.invalidate();
|
||||||
|
setOpen(false);
|
||||||
|
setEditId(null);
|
||||||
|
setForm(defaultForm);
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
const del = api.expenses.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Expense deleted");
|
||||||
|
void utils.expenses.getAll.invalidate();
|
||||||
|
setDeleteId(null);
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
setEditId(null);
|
||||||
|
setForm(defaultForm);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
const handleEdit = (expense: (typeof expenses)[0]) => {
|
||||||
|
setEditId(expense.id);
|
||||||
|
setForm({
|
||||||
|
date: new Date(expense.date),
|
||||||
|
description: expense.description,
|
||||||
|
amount: expense.amount,
|
||||||
|
currency: expense.currency,
|
||||||
|
category: expense.category ?? "",
|
||||||
|
billable: expense.billable,
|
||||||
|
reimbursable: expense.reimbursable,
|
||||||
|
taxDeductible: expense.taxDeductible ?? false,
|
||||||
|
notes: expense.notes ?? "",
|
||||||
|
clientId: expense.clientId ?? "",
|
||||||
|
});
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!form.description.trim()) {
|
||||||
|
toast.error("Description is required");
|
||||||
|
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 });
|
||||||
|
else create.mutate(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 deductibleTotal = expenses
|
||||||
|
.filter((e) => e.taxDeductible)
|
||||||
|
.reduce((s, e) => s + e.amount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-enter space-y-6 pb-6">
|
||||||
|
<PageHeader
|
||||||
|
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
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||||
|
Total
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold">
|
||||||
|
{formatCurrency(totalExpenses)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||||
|
Billable
|
||||||
|
</p>
|
||||||
|
<p className="text-primary mt-1 text-2xl font-bold">
|
||||||
|
{formatCurrency(billableTotal)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||||
|
Deductible
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-green-600">
|
||||||
|
{formatCurrency(deductibleTotal)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expenses list */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Receipt className="h-5 w-5" /> All Expenses
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground p-6 text-center text-sm">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
) : expenses.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{expenses.map((expense) => (
|
||||||
|
<div
|
||||||
|
key={expense.id}
|
||||||
|
className="flex items-start justify-between gap-3 p-4"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="font-medium">{expense.description}</p>
|
||||||
|
{expense.billable && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Billable
|
||||||
|
</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>
|
||||||
|
<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))}
|
||||||
|
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||||
|
</p>
|
||||||
|
{expense.notes && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
{expense.notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
<p className="font-semibold">
|
||||||
|
{formatCurrency(expense.amount, expense.currency)}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Add/Edit dialog */}
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((p) => ({ ...p, description: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="e.g. Laptop charger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Amount *</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={form.amount}
|
||||||
|
onChange={(v) => setForm((p) => ({ ...p, amount: v }))}
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Currency</Label>
|
||||||
|
<Select
|
||||||
|
value={form.currency}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Date</Label>
|
||||||
|
<DatePicker
|
||||||
|
date={form.date}
|
||||||
|
onDateChange={(d) =>
|
||||||
|
setForm((p) => ({ ...p, date: d ?? new Date() }))
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category</Label>
|
||||||
|
<Select
|
||||||
|
value={form.category || "none"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((p) => ({ ...p, category: v === "none" ? "" : v }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{EXPENSE_CATEGORIES.map((c) => (
|
||||||
|
<SelectItem key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Client (optional)</Label>
|
||||||
|
<Select
|
||||||
|
value={form.clientId || "none"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="No client" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">No client</SelectItem>
|
||||||
|
{clients.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.billable}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setForm((p) => ({ ...p, billable: !!v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Billable</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.reimbursable}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setForm((p) => ({ ...p, reimbursable: !!v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Reimbursable</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.taxDeductible}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setForm((p) => ({ ...p, taxDeductible: !!v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Tax Deductible</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Notes (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((p) => ({ ...p, notes: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Additional details…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={create.isPending || update.isPending}
|
||||||
|
>
|
||||||
|
{create.isPending || update.isPending
|
||||||
|
? "Saving…"
|
||||||
|
: editId
|
||||||
|
? "Update"
|
||||||
|
: "Add Expense"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete dialog */}
|
||||||
|
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Expense</DialogTitle>
|
||||||
|
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteId && del.mutate({ id: deleteId })}
|
||||||
|
disabled={del.isPending}
|
||||||
|
>
|
||||||
|
{del.isPending ? "Deleting…" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,15 +30,15 @@ export function InvoiceDetailsSkeleton() {
|
|||||||
<Skeleton className="h-8 w-48" />
|
<Skeleton className="h-8 w-48" />
|
||||||
<Skeleton className="h-6 w-24 rounded-full" />
|
<Skeleton className="h-6 w-24 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 sm:space-y-0 text-sm">
|
<div className="space-y-1 text-sm sm:space-y-0">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-4 w-32" />
|
||||||
<Skeleton className="h-4 w-32 hidden sm:block" />
|
<Skeleton className="hidden h-4 w-32 sm:block" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-left sm:text-right">
|
<div className="flex-shrink-0 text-left sm:text-right">
|
||||||
<Skeleton className="h-4 w-24 mb-1 sm:ml-auto" />
|
<Skeleton className="mb-1 h-4 w-24 sm:ml-auto" />
|
||||||
<Skeleton className="h-9 w-32 sm:ml-auto" />
|
<Skeleton className="h-9 w-32 sm:ml-auto" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +118,7 @@ export function InvoiceDetailsSkeleton() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<Skeleton className="h-5 w-3/4 mb-2" />
|
<Skeleton className="mb-2 h-5 w-3/4" />
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-4 w-24" />
|
||||||
<Skeleton className="h-4 w-16" />
|
<Skeleton className="h-4 w-16" />
|
||||||
@@ -156,7 +156,7 @@ export function InvoiceDetailsSkeleton() {
|
|||||||
|
|
||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="sticky top-20">
|
<Card className="lg:sticky lg:top-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Skeleton className="h-5 w-5 rounded-full" />
|
<Skeleton className="h-5 w-5 rounded-full" />
|
||||||
|
|||||||
@@ -40,13 +40,31 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
accessorKey: "date",
|
accessorKey: "date",
|
||||||
header: "Date",
|
header: "Date",
|
||||||
cell: ({ row }) => formatDate(row.getValue("date")),
|
cell: ({ row }) => formatDate(row.getValue("date")),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden sm:table-cell",
|
||||||
|
cellClassName: "hidden sm:table-cell",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "description",
|
accessorKey: "description",
|
||||||
header: "Description",
|
header: "Description",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<div className="font-medium">{row.getValue("description")}</div>
|
const item = row.original;
|
||||||
),
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop: plain description */}
|
||||||
|
<div className="hidden font-medium sm:block">{item.description}</div>
|
||||||
|
{/* Mobile: description + date + hours @ rate stacked */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<p className="font-medium">{item.description}</p>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
{formatDate(item.date)} · {item.hours}h @{" "}
|
||||||
|
{formatCurrency(item.rate)}/hr
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "hours",
|
accessorKey: "hours",
|
||||||
@@ -54,6 +72,10 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-right">{row.getValue("hours")}</div>
|
<div className="text-right">{row.getValue("hours")}</div>
|
||||||
),
|
),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden sm:table-cell",
|
||||||
|
cellClassName: "hidden sm:table-cell",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "rate",
|
accessorKey: "rate",
|
||||||
@@ -61,6 +83,10 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
|
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
|
||||||
),
|
),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden sm:table-cell",
|
||||||
|
cellClassName: "hidden sm:table-cell",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "amount",
|
accessorKey: "amount",
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export function PDFDownloadButton({
|
|||||||
{ id: invoiceId },
|
{ id: invoiceId },
|
||||||
{ enabled: false },
|
{ enabled: false },
|
||||||
);
|
);
|
||||||
|
const { data: platformTheme } = api.settings.getTheme.useQuery(undefined, {
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
const handleDownloadPDF = async () => {
|
const handleDownloadPDF = async () => {
|
||||||
if (isGenerating) return;
|
if (isGenerating) return;
|
||||||
@@ -39,7 +42,29 @@ export function PDFDownloadButton({
|
|||||||
throw new Error("Invoice not found");
|
throw new Error("Invoice not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
await generateInvoicePDF(invoiceData);
|
// Map invoice to PDF format with currency support
|
||||||
|
const pdfData = {
|
||||||
|
invoiceNumber: invoiceData.invoiceNumber,
|
||||||
|
invoicePrefix: invoiceData.invoicePrefix,
|
||||||
|
issueDate: new Date(invoiceData.issueDate),
|
||||||
|
dueDate: new Date(invoiceData.dueDate),
|
||||||
|
status: invoiceData.status,
|
||||||
|
totalAmount: invoiceData.totalAmount,
|
||||||
|
taxRate: invoiceData.taxRate,
|
||||||
|
currency: invoiceData.currency ?? "USD",
|
||||||
|
notes: invoiceData.notes,
|
||||||
|
business: invoiceData.business,
|
||||||
|
client: invoiceData.client,
|
||||||
|
items: invoiceData.items,
|
||||||
|
};
|
||||||
|
|
||||||
|
await generateInvoicePDF(pdfData, {
|
||||||
|
pdfTemplate: platformTheme?.pdfTemplate,
|
||||||
|
pdfAccentColor: platformTheme?.pdfAccentColor,
|
||||||
|
pdfFooterText: platformTheme?.pdfFooterText,
|
||||||
|
pdfShowLogo: platformTheme?.pdfShowLogo,
|
||||||
|
pdfShowPageNumbers: platformTheme?.pdfShowPageNumbers,
|
||||||
|
});
|
||||||
toast.success("PDF downloaded successfully");
|
toast.success("PDF downloaded successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("PDF generation error:", error);
|
console.error("PDF generation error:", error);
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import { Send, Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
interface SendInvoiceButtonProps {
|
|
||||||
invoiceId: string;
|
|
||||||
variant?: "default" | "outline" | "ghost" | "icon";
|
|
||||||
className?: string;
|
|
||||||
showResend?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SendInvoiceButton({
|
|
||||||
invoiceId,
|
|
||||||
variant = "outline",
|
|
||||||
className,
|
|
||||||
showResend = false,
|
|
||||||
}: SendInvoiceButtonProps) {
|
|
||||||
const [isSending, setIsSending] = useState(false);
|
|
||||||
|
|
||||||
// Get utils for cache invalidation
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
// Use the new email API mutation
|
|
||||||
const sendInvoiceMutation = api.email.sendInvoice.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
// Show detailed success message with delivery info
|
|
||||||
toast.success(data.message, {
|
|
||||||
description: `Email ID: ${data.emailId}`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refresh invoice data to show updated status
|
|
||||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
// Enhanced error handling with specific error types
|
|
||||||
console.error("Email send error:", error);
|
|
||||||
|
|
||||||
let errorMessage = "Failed to send invoice email";
|
|
||||||
let errorDescription = "";
|
|
||||||
|
|
||||||
if (error.message.includes("Invalid recipient")) {
|
|
||||||
errorMessage = "Invalid Email Address";
|
|
||||||
errorDescription =
|
|
||||||
"Please check the client's email address and try again.";
|
|
||||||
} else if (error.message.includes("domain not verified")) {
|
|
||||||
errorMessage = "Email Configuration Issue";
|
|
||||||
errorDescription = "Please contact support to configure email sending.";
|
|
||||||
} else if (error.message.includes("rate limit")) {
|
|
||||||
errorMessage = "Too Many Emails";
|
|
||||||
errorDescription = "Please wait a moment before sending another email.";
|
|
||||||
} else if (error.message.includes("no email address")) {
|
|
||||||
errorMessage = "No Email Address";
|
|
||||||
errorDescription = "This client doesn't have an email address on file.";
|
|
||||||
} else {
|
|
||||||
errorDescription = error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error(errorMessage, {
|
|
||||||
description: errorDescription,
|
|
||||||
duration: 6000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSendInvoice = async () => {
|
|
||||||
if (isSending) return;
|
|
||||||
|
|
||||||
setIsSending(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendInvoiceMutation.mutateAsync({
|
|
||||||
invoiceId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Error is already handled by the mutation's onError
|
|
||||||
console.error("Send invoice error:", error);
|
|
||||||
} finally {
|
|
||||||
setIsSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (variant === "icon") {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={handleSendInvoice}
|
|
||||||
disabled={isSending}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{isSending ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
|
|
||||||
) : (
|
|
||||||
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={handleSendInvoice}
|
|
||||||
disabled={isSending}
|
|
||||||
variant={variant}
|
|
||||||
size="default"
|
|
||||||
className={`w-full shadow-sm ${className}`}
|
|
||||||
data-testid="send-invoice-button"
|
|
||||||
>
|
|
||||||
{isSending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
<span>Sending Email...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Send className="mr-2 h-4 w-4" />
|
|
||||||
<span>{showResend ? "Resend Invoice" : "Send Invoice"}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { InvoiceView } from "~/components/data/invoice-view";
|
|
||||||
import InvoiceForm from "~/components/forms/invoice-form";
|
|
||||||
|
|
||||||
interface UnifiedInvoicePageProps {
|
|
||||||
invoiceId: string;
|
|
||||||
mode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UnifiedInvoicePage({
|
|
||||||
invoiceId,
|
|
||||||
mode,
|
|
||||||
}: UnifiedInvoicePageProps) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Always render InvoiceForm to preserve state, but hide when in view mode */}
|
|
||||||
<div className={mode === "edit" ? "block" : "hidden"}>
|
|
||||||
<InvoiceForm invoiceId={invoiceId} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Show InvoiceView only when in view mode */}
|
|
||||||
{mode === "view" && <InvoiceView invoiceId={invoiceId} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -75,7 +75,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
const handleMarkAsPaid = () => {
|
const handleMarkAsPaid = () => {
|
||||||
updateStatus.mutate({
|
updateStatus.mutate({
|
||||||
id: invoiceId,
|
id: invoiceId,
|
||||||
status: "paid" as StoredInvoiceStatus,
|
status: "paid",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,27 +99,25 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
}).format(new Date(date));
|
}).format(new Date(date));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number, currency = invoice.currency) => {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency,
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||||
const total = subtotal + taxAmount;
|
const total = subtotal + taxAmount;
|
||||||
|
const storedStatus = invoice.status as StoredInvoiceStatus;
|
||||||
const effectiveStatus = getEffectiveInvoiceStatus(
|
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||||
invoice.status as StoredInvoiceStatus,
|
storedStatus,
|
||||||
invoice.dueDate,
|
|
||||||
);
|
|
||||||
const isOverdue = isInvoiceOverdue(
|
|
||||||
invoice.status as StoredInvoiceStatus,
|
|
||||||
invoice.dueDate,
|
invoice.dueDate,
|
||||||
);
|
);
|
||||||
|
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate);
|
||||||
|
|
||||||
const getStatusType = (): StatusType => {
|
const getStatusType = (): StatusType => {
|
||||||
return effectiveStatus as StatusType;
|
return effectiveStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -411,7 +409,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="sticky top-20">
|
<Card className="lg:sticky lg:top-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Check className="h-5 w-5" />
|
<Check className="h-5 w-5" />
|
||||||
|
|||||||
@@ -54,6 +54,32 @@ function SendEmailPageSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function plainTextToHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmailNoteHtml(value: string) {
|
||||||
|
const visibleText = value
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/p>/gi, "\n")
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/ |\u00a0/g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return visibleText ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
export default function SendEmailPage() {
|
export default function SendEmailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -155,7 +181,10 @@ export default function SendEmailPage() {
|
|||||||
issueDate: invoiceData.issueDate,
|
issueDate: invoiceData.issueDate,
|
||||||
dueDate: invoiceData.dueDate,
|
dueDate: invoiceData.dueDate,
|
||||||
status: invoiceData.status,
|
status: invoiceData.status,
|
||||||
|
totalAmount: invoiceData.totalAmount,
|
||||||
taxRate: invoiceData.taxRate,
|
taxRate: invoiceData.taxRate,
|
||||||
|
currency: invoiceData.currency,
|
||||||
|
emailMessage: invoiceData.emailMessage,
|
||||||
client: invoiceData.client
|
client: invoiceData.client
|
||||||
? {
|
? {
|
||||||
name: invoiceData.client.name,
|
name: invoiceData.client.name,
|
||||||
@@ -171,13 +200,21 @@ export default function SendEmailPage() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
items: invoiceData.items?.map((item) => ({
|
items: invoiceData.items?.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
date: item.date,
|
||||||
|
description: item.description,
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
|
amount: item.amount,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
}, [invoiceData]);
|
}, [invoiceData]);
|
||||||
|
|
||||||
|
const normalizedCustomMessage = useMemo(
|
||||||
|
() => normalizeEmailNoteHtml(customMessage),
|
||||||
|
[customMessage],
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize email content when invoice loads
|
// Initialize email content when invoice loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!invoice || isInitialized) return;
|
if (!invoice || isInitialized) return;
|
||||||
@@ -191,6 +228,9 @@ export default function SendEmailPage() {
|
|||||||
const defaultContent = ``;
|
const defaultContent = ``;
|
||||||
|
|
||||||
setEmailContent(defaultContent);
|
setEmailContent(defaultContent);
|
||||||
|
setCustomMessage(
|
||||||
|
invoice.emailMessage ? plainTextToHtml(invoice.emailMessage) : "",
|
||||||
|
);
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
}, [invoice, isInitialized]);
|
}, [invoice, isInitialized]);
|
||||||
|
|
||||||
@@ -222,7 +262,7 @@ export default function SendEmailPage() {
|
|||||||
invoiceId,
|
invoiceId,
|
||||||
customSubject: subject,
|
customSubject: subject,
|
||||||
customContent: emailContent,
|
customContent: emailContent,
|
||||||
customMessage: customMessage?.trim() || undefined,
|
customMessage: normalizedCustomMessage,
|
||||||
useHtml: true,
|
useHtml: true,
|
||||||
ccEmails: ccEmail.trim() || undefined,
|
ccEmails: ccEmail.trim() || undefined,
|
||||||
bccEmails: bccEmail.trim() || undefined,
|
bccEmails: bccEmail.trim() || undefined,
|
||||||
@@ -252,7 +292,7 @@ export default function SendEmailPage() {
|
|||||||
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-4xl p-6">
|
<div className="page-enter space-y-6">
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertDescription>Invoice not found.</AlertDescription>
|
<AlertDescription>Invoice not found.</AlertDescription>
|
||||||
@@ -262,7 +302,7 @@ export default function SendEmailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-6xl space-y-6 pb-32">
|
<div className="page-enter space-y-6 pb-32">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={`Send Invoice ${invoice.invoiceNumber}`}
|
title={`Send Invoice ${invoice.invoiceNumber}`}
|
||||||
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"} • ${new Intl.DateTimeFormat(
|
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"} • ${new Intl.DateTimeFormat(
|
||||||
@@ -366,7 +406,7 @@ export default function SendEmailPage() {
|
|||||||
ccEmail={ccEmail}
|
ccEmail={ccEmail}
|
||||||
bccEmail={bccEmail}
|
bccEmail={bccEmail}
|
||||||
content={emailContent}
|
content={emailContent}
|
||||||
customMessage={customMessage}
|
customMessage={normalizedCustomMessage}
|
||||||
invoice={invoice}
|
invoice={invoice}
|
||||||
className="min-w-0 border-0"
|
className="min-w-0 border-0"
|
||||||
/>
|
/>
|
||||||
@@ -552,10 +592,9 @@ export default function SendEmailPage() {
|
|||||||
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Send Invoice Email?</DialogTitle>
|
<DialogTitle>Confirm</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This will send invoice #{invoice.invoiceNumber} to{" "}
|
Send this invoice email to <strong>{toEmail}</strong>
|
||||||
<strong>{invoice.client?.email}</strong>
|
|
||||||
{ccEmail && (
|
{ccEmail && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
@@ -568,14 +607,30 @@ export default function SendEmailPage() {
|
|||||||
and BCC to <strong>{bccEmail}</strong>
|
and BCC to <strong>{bccEmail}</strong>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
.
|
?
|
||||||
|
</DialogDescription>
|
||||||
{retryCount > 0 && (
|
{retryCount > 0 && (
|
||||||
<div className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Retry attempt {retryCount} of 2
|
Retry attempt {retryCount} of 2
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="bg-muted/30 space-y-2 border p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Subject: </span>
|
||||||
|
<span className="font-medium">{subject}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Attachment: </span>
|
||||||
|
<span>invoice-{invoice.invoiceNumber}.pdf</span>
|
||||||
|
</div>
|
||||||
|
{normalizedCustomMessage && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Email note: </span>
|
||||||
|
<span>Included</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</div>
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -584,8 +639,7 @@ export default function SendEmailPage() {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={confirmSendEmail} variant="default">
|
<Button onClick={confirmSendEmail} variant="default">
|
||||||
<Send className="mr-2 h-4 w-4" />
|
Confirm
|
||||||
Send Email
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef, Row } from "@tanstack/react-table";
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||||
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
|
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
|
||||||
@@ -16,13 +17,27 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/dialog";
|
||||||
import { Eye, Edit, Trash2, FileText } from "lucide-react";
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "~/components/ui/dropdown-menu";
|
||||||
|
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";
|
||||||
|
import { formatCurrency } from "~/lib/currency";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
|
||||||
// Type for invoice data
|
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
id: string;
|
id: string;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
@@ -33,6 +48,7 @@ interface Invoice {
|
|||||||
status: string;
|
status: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
|
currency: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdById: string;
|
createdById: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -66,62 +82,85 @@ interface InvoicesDataTableProps {
|
|||||||
invoices: Invoice[];
|
invoices: Invoice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusType = (invoice: Invoice): StatusType => {
|
const getStatusType = (invoice: Invoice): StatusType =>
|
||||||
return getEffectiveInvoiceStatus(
|
getEffectiveInvoiceStatus(
|
||||||
invoice.status as StoredInvoiceStatus,
|
invoice.status as StoredInvoiceStatus,
|
||||||
invoice.dueDate,
|
invoice.dueDate,
|
||||||
) as StatusType;
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) =>
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
new Intl.DateTimeFormat("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
}).format(new Date(date));
|
}).format(new Date(date));
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
|
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
|
||||||
|
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||||
|
const [pendingBulkDelete, setPendingBulkDelete] = useState<Invoice[]>([]);
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const deleteInvoice = api.invoices.delete.useMutation({
|
const deleteInvoice = api.invoices.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Invoice deleted successfully");
|
toast.success("Invoice deleted");
|
||||||
void utils.invoices.getAll.invalidate();
|
void utils.invoices.getAll.invalidate();
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setInvoiceToDelete(null);
|
setInvoiceToDelete(null);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (e) => toast.error(e.message ?? "Failed to delete invoice"),
|
||||||
toast.error(error.message ?? "Failed to delete invoice");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRowClick = (invoice: Invoice) => {
|
const bulkDelete = api.invoices.bulkDelete.useMutation({
|
||||||
router.push(`/dashboard/invoices/${invoice.id}`);
|
onSuccess: (data) => {
|
||||||
};
|
toast.success(
|
||||||
|
`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`,
|
||||||
|
);
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
setBulkDeleteDialogOpen(false);
|
||||||
|
setPendingBulkDelete([]);
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message ?? "Failed to delete invoices"),
|
||||||
|
});
|
||||||
|
|
||||||
const handleDelete = (invoice: Invoice) => {
|
const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({
|
||||||
setInvoiceToDelete(invoice);
|
onSuccess: (data) => {
|
||||||
setDeleteDialogOpen(true);
|
toast.success(
|
||||||
};
|
`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`,
|
||||||
|
);
|
||||||
const confirmDelete = () => {
|
void utils.invoices.getAll.invalidate();
|
||||||
if (invoiceToDelete) {
|
},
|
||||||
deleteInvoice.mutate({ id: invoiceToDelete.id });
|
onError: (e) => toast.error(e.message ?? "Failed to update invoices"),
|
||||||
}
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnDef<Invoice>[] = [
|
const columns: ColumnDef<Invoice>[] = [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
table.getIsAllPageRowsSelected() ||
|
||||||
|
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||||
|
}
|
||||||
|
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
|
||||||
|
aria-label="Select all"
|
||||||
|
data-action-button="true"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }: { row: Row<Invoice> }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||||
|
aria-label="Select row"
|
||||||
|
data-action-button="true"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "client.name",
|
accessorKey: "client.name",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@@ -134,13 +173,22 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
<div className="bg-primary/10 hidden p-2 sm:flex">
|
<div className="bg-primary/10 hidden p-2 sm:flex">
|
||||||
<FileText className="text-primary h-4 w-4" />
|
<FileText className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-[80px] min-w-0 sm:max-w-[200px] lg:max-w-[300px]">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-medium">
|
<p className="truncate font-medium">
|
||||||
{invoice.client?.name ?? "—"}
|
{invoice.client?.name ?? "—"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
||||||
{invoice.invoiceNumber}
|
{invoice.invoiceNumber}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-1 flex items-center gap-2 sm:hidden">
|
||||||
|
<StatusBadge
|
||||||
|
status={getStatusType(invoice)}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-foreground text-xs font-semibold">
|
||||||
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -151,39 +199,32 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Date" />
|
<DataTableColumnHeader column={column} title="Date" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const date = row.getValue("issueDate");
|
|
||||||
return (
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm">{formatDate(date as Date)}</p>
|
<p className="truncate text-sm">
|
||||||
|
{formatDate(row.getValue("issueDate"))}
|
||||||
|
</p>
|
||||||
<p className="text-muted-foreground truncate text-xs">
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
Due {formatDate(new Date(row.original.dueDate))}
|
Due {formatDate(new Date(row.original.dueDate))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Status" />
|
<DataTableColumnHeader column={column} title="Status" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const invoice = row.original;
|
|
||||||
return (
|
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={getStatusType(invoice)}
|
status={getStatusType(row.original)}
|
||||||
className={
|
className={
|
||||||
getStatusType(invoice) === "sent" ? "status-pending" : ""
|
getStatusType(row.original) === "sent" ? "status-pending" : ""
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
),
|
||||||
},
|
filterFn: (row, _id, value: string[]) =>
|
||||||
filterFn: (row, id, value: string[]) => {
|
value.includes(getStatusType(row.original)),
|
||||||
const invoice = row.original;
|
|
||||||
const status = getStatusType(invoice);
|
|
||||||
return value.includes(status);
|
|
||||||
},
|
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "hidden sm:table-cell",
|
headerClassName: "hidden sm:table-cell",
|
||||||
cellClassName: "hidden sm:table-cell",
|
cellClassName: "hidden sm:table-cell",
|
||||||
@@ -194,19 +235,16 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title="Amount" />
|
<DataTableColumnHeader column={column} title="Amount" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const amount = row.getValue("totalAmount");
|
|
||||||
return (
|
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-semibold">
|
<p className="text-sm font-semibold">
|
||||||
{formatCurrency(amount as number)}
|
{formatCurrency(row.getValue("totalAmount"), row.original.currency)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{row.original.items?.length ?? 0} items
|
{row.original.items?.length ?? 0} items
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
),
|
||||||
},
|
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: "hidden sm:table-cell",
|
headerClassName: "hidden sm:table-cell",
|
||||||
cellClassName: "hidden sm:table-cell",
|
cellClassName: "hidden sm:table-cell",
|
||||||
@@ -244,7 +282,8 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDelete(invoice);
|
setInvoiceToDelete(invoice);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
data-action-button="true"
|
data-action-button="true"
|
||||||
>
|
>
|
||||||
@@ -282,10 +321,74 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
searchKey="invoiceNumber"
|
searchKey="invoiceNumber"
|
||||||
searchPlaceholder="Search invoices..."
|
searchPlaceholder="Search invoices..."
|
||||||
filterableColumns={filterableColumns}
|
filterableColumns={filterableColumns}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={(invoice) =>
|
||||||
|
router.push(`/dashboard/invoices/${invoice.id}`)
|
||||||
|
}
|
||||||
|
selectionActions={(selected, clear) => (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={bulkUpdateStatus.isPending}
|
||||||
|
>
|
||||||
|
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Mark as
|
||||||
|
<ChevronDown className="ml-1.5 h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
bulkUpdateStatus.mutate(
|
||||||
|
{ ids: selected.map((i) => i.id), status: "sent" },
|
||||||
|
{ onSuccess: clear },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-4 w-4" /> Mark Sent
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
bulkUpdateStatus.mutate(
|
||||||
|
{ ids: selected.map((i) => i.id), status: "paid" },
|
||||||
|
{ onSuccess: clear },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" /> Mark Paid
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
bulkUpdateStatus.mutate(
|
||||||
|
{ ids: selected.map((i) => i.id), status: "draft" },
|
||||||
|
{ onSuccess: clear },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FileText className="mr-2 h-4 w-4" /> Mark Draft
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={bulkDelete.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setPendingBulkDelete(selected);
|
||||||
|
setBulkDeleteDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Delete ({selected.length})
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Single delete dialog */}
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -307,7 +410,10 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={confirmDelete}
|
onClick={() =>
|
||||||
|
invoiceToDelete &&
|
||||||
|
deleteInvoice.mutate({ id: invoiceToDelete.id })
|
||||||
|
}
|
||||||
disabled={deleteInvoice.isPending}
|
disabled={deleteInvoice.isPending}
|
||||||
>
|
>
|
||||||
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
||||||
@@ -315,6 +421,46 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Bulk delete dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={bulkDeleteDialogOpen}
|
||||||
|
onOpenChange={setBulkDeleteDialogOpen}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Delete {pendingBulkDelete.length} Invoice
|
||||||
|
{pendingBulkDelete.length !== 1 ? "s" : ""}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will permanently delete {pendingBulkDelete.length} invoice
|
||||||
|
{pendingBulkDelete.length !== 1 ? "s" : ""}. This action cannot be
|
||||||
|
undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setBulkDeleteDialogOpen(false)}
|
||||||
|
disabled={bulkDelete.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() =>
|
||||||
|
bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })
|
||||||
|
}
|
||||||
|
disabled={bulkDelete.isPending}
|
||||||
|
>
|
||||||
|
{bulkDelete.isPending
|
||||||
|
? "Deleting..."
|
||||||
|
: `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api, type RouterOutputs } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
|
||||||
|
|
||||||
|
interface TemplateForm {
|
||||||
|
name: string;
|
||||||
|
type: "notes" | "terms";
|
||||||
|
content: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultForm: TemplateForm = {
|
||||||
|
name: "",
|
||||||
|
type: "notes",
|
||||||
|
content: "",
|
||||||
|
isDefault: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
type InvoiceTemplate = RouterOutputs["invoiceTemplates"]["getAll"][number];
|
||||||
|
|
||||||
|
interface TemplateListProps {
|
||||||
|
items: InvoiceTemplate[];
|
||||||
|
type: "notes" | "terms";
|
||||||
|
isLoading: boolean;
|
||||||
|
onCreate: (type: "notes" | "terms") => void;
|
||||||
|
onEdit: (template: InvoiceTemplate) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateList({
|
||||||
|
items,
|
||||||
|
type,
|
||||||
|
isLoading,
|
||||||
|
onCreate,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: TemplateListProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => onCreate(type)}>
|
||||||
|
<Plus className="mr-1.5 h-3.5 w-3.5" /> New{" "}
|
||||||
|
{type === "notes" ? "Notes" : "Terms"} Template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
|
No {type} templates yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((template) => (
|
||||||
|
<Card key={template.id}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium">{template.name}</p>
|
||||||
|
{template.isDefault && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
<Star className="mr-1 h-3 w-3" /> Default
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap">
|
||||||
|
{template.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => onEdit(template)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<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" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</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 (
|
||||||
|
<div className="page-enter space-y-6 pb-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Invoice Templates"
|
||||||
|
description="Reusable notes and payment terms for your invoices"
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="notes">
|
||||||
|
<FileText className="mr-1.5 h-4 w-4" /> Notes (
|
||||||
|
{notesTemplates.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="terms">
|
||||||
|
<FileText className="mr-1.5 h-4 w-4" /> Terms (
|
||||||
|
{termsTemplates.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="notes" className="mt-4">
|
||||||
|
<TemplateList
|
||||||
|
items={notesTemplates}
|
||||||
|
type="notes"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCreate={handleOpen}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={setDeleteId}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="terms" className="mt-4">
|
||||||
|
<TemplateList
|
||||||
|
items={termsTemplates}
|
||||||
|
type="terms"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onCreate={handleOpen}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={setDeleteId}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Create/Edit dialog */}
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editId ? "Edit Template" : "New Template"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((p) => ({ ...p, name: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="e.g. Standard Payment Terms"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Tabs
|
||||||
|
value={form.type}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((p) => ({ ...p, type: v as "notes" | "terms" }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||||
|
<TabsTrigger value="terms">Terms</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Content *</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.content}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((p) => ({ ...p, content: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Template content…"
|
||||||
|
className="min-h-[120px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.isDefault}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setForm((p) => ({ ...p, isDefault: !!v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Set as default for {form.type}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={create.isPending || update.isPending}
|
||||||
|
>
|
||||||
|
{create.isPending || update.isPending
|
||||||
|
? "Saving…"
|
||||||
|
: editId
|
||||||
|
? "Update"
|
||||||
|
: "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete dialog */}
|
||||||
|
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Template</DialogTitle>
|
||||||
|
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteId && del.mutate({ id: deleteId })}
|
||||||
|
disabled={del.isPending}
|
||||||
|
>
|
||||||
|
{del.isPending ? "Deleting…" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,9 +28,9 @@ import type { DashboardStats, RecentInvoice } from "./types";
|
|||||||
|
|
||||||
// Hero section with clean mono design
|
// Hero section with clean mono design
|
||||||
|
|
||||||
|
|
||||||
// Enhanced stats cards with better visuals
|
// Enhanced stats cards with better visuals
|
||||||
function DashboardStats({ stats }: { stats: DashboardStats }) { // TODO: Import RouterOutput type
|
function DashboardStats({ stats }: { stats: DashboardStats }) {
|
||||||
|
// TODO: Import RouterOutput type
|
||||||
const formatTrend = (value: number, isCount = false) => {
|
const formatTrend = (value: number, isCount = false) => {
|
||||||
if (isCount) {
|
if (isCount) {
|
||||||
return value > 0 ? `+${value}` : value.toString();
|
return value > 0 ? `+${value}` : value.toString();
|
||||||
@@ -193,7 +193,8 @@ function QuickActions() {
|
|||||||
<Link
|
<Link
|
||||||
key={action.title}
|
key={action.title}
|
||||||
href={action.href}
|
href={action.href}
|
||||||
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${action.featured
|
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${
|
||||||
|
action.featured
|
||||||
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
? "border-foreground/20 bg-muted/50 hover:bg-muted"
|
||||||
: "border-border bg-background hover:bg-muted/50"
|
: "border-border bg-background hover:bg-muted/50"
|
||||||
}`}
|
}`}
|
||||||
@@ -310,7 +311,11 @@ async function CurrentWork() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced recent activity
|
// Enhanced recent activity
|
||||||
async function RecentActivity({ recentInvoices }: { recentInvoices: RecentInvoice[] }) {
|
async function RecentActivity({
|
||||||
|
recentInvoices,
|
||||||
|
}: {
|
||||||
|
recentInvoices: RecentInvoice[];
|
||||||
|
}) {
|
||||||
// Use passed recentInvoices instead of fetching all
|
// Use passed recentInvoices instead of fetching all
|
||||||
|
|
||||||
const getStatusStyle = (status: string) => {
|
const getStatusStyle = (status: string) => {
|
||||||
|
|||||||
@@ -0,0 +1,847 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { StatusBadge } from "~/components/data/status-badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
|
import { formatCurrency } from "~/lib/currency";
|
||||||
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Download,
|
||||||
|
Receipt,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
function toNumericChartValue(value: unknown) {
|
||||||
|
const numericValue = typeof value === "number" ? value : Number(value ?? 0);
|
||||||
|
return Number.isFinite(numericValue) ? numericValue : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportsPage() {
|
||||||
|
const { data: invoices = [], isLoading: invoicesLoading } =
|
||||||
|
api.invoices.getAll.useQuery();
|
||||||
|
const { data: expenses = [], isLoading: expensesLoading } =
|
||||||
|
api.expenses.getAll.useQuery();
|
||||||
|
const { data: stats } = api.dashboard.getStats.useQuery();
|
||||||
|
|
||||||
|
const isLoading = invoicesLoading || expensesLoading;
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const [taxYear, setTaxYear] = useState(String(currentYear));
|
||||||
|
|
||||||
|
// Overview data (last 12 months)
|
||||||
|
const overviewData = useMemo(() => {
|
||||||
|
if (!invoices.length) return null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const monthMap: Record<string, number> = {};
|
||||||
|
for (let i = 11; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
monthMap[key] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalRevenue = 0;
|
||||||
|
let totalPending = 0;
|
||||||
|
let totalHours = 0;
|
||||||
|
|
||||||
|
for (const inv of invoices) {
|
||||||
|
const status = getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
);
|
||||||
|
if (status === "paid") {
|
||||||
|
totalRevenue += inv.totalAmount;
|
||||||
|
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount;
|
||||||
|
} else if (status === "sent" || status === "overdue") {
|
||||||
|
totalPending += inv.totalAmount;
|
||||||
|
}
|
||||||
|
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
|
||||||
|
month: new Date(month + "-01").toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
}),
|
||||||
|
revenue,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
||||||
|
for (const inv of invoices) {
|
||||||
|
const status = getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
);
|
||||||
|
if (status === "paid" && inv.client) {
|
||||||
|
const id = inv.client.id;
|
||||||
|
const entry = (clientMap[id] ??= {
|
||||||
|
name: inv.client.name,
|
||||||
|
revenue: 0,
|
||||||
|
});
|
||||||
|
entry.revenue += inv.totalAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const topClients = Object.values(clientMap)
|
||||||
|
.sort((a, b) => b.revenue - a.revenue)
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
const statusCount: Record<string, number> = {
|
||||||
|
draft: 0,
|
||||||
|
sent: 0,
|
||||||
|
paid: 0,
|
||||||
|
overdue: 0,
|
||||||
|
};
|
||||||
|
for (const inv of invoices) {
|
||||||
|
const s = getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
);
|
||||||
|
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
revenueByMonth,
|
||||||
|
topClients,
|
||||||
|
totalRevenue,
|
||||||
|
totalPending,
|
||||||
|
totalHours,
|
||||||
|
statusCount,
|
||||||
|
};
|
||||||
|
}, [invoices]);
|
||||||
|
|
||||||
|
// Tax summary for selected year
|
||||||
|
const taxData = useMemo(() => {
|
||||||
|
const year = parseInt(taxYear);
|
||||||
|
|
||||||
|
const yearInvoices = invoices.filter((inv) => {
|
||||||
|
const status = getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
status === "paid" && new Date(inv.issueDate).getFullYear() === year
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const yearExpenses = expenses.filter(
|
||||||
|
(exp) => new Date(exp.date).getFullYear() === year,
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSubtotal = (inv: (typeof yearInvoices)[number]) => {
|
||||||
|
const itemSubtotal = (inv.items ?? []).reduce(
|
||||||
|
(s, item) => s + item.amount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (itemSubtotal > 0) return itemSubtotal;
|
||||||
|
|
||||||
|
const taxMultiplier = 1 + (inv.taxRate ?? 0) / 100;
|
||||||
|
return taxMultiplier > 0
|
||||||
|
? inv.totalAmount / taxMultiplier
|
||||||
|
: inv.totalAmount;
|
||||||
|
};
|
||||||
|
|
||||||
|
const grossIncome = yearInvoices.reduce(
|
||||||
|
(s, inv) => s + getSubtotal(inv),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const taxCollected = yearInvoices.reduce(
|
||||||
|
(s, inv) => s + (inv.totalAmount - getSubtotal(inv)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0);
|
||||||
|
const deductibleExpenses = yearExpenses
|
||||||
|
.filter(
|
||||||
|
(exp) =>
|
||||||
|
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible,
|
||||||
|
)
|
||||||
|
.reduce((s, exp) => s + exp.amount, 0);
|
||||||
|
|
||||||
|
const netProfit = grossIncome - deductibleExpenses;
|
||||||
|
const seTaxBase = Math.max(0, netProfit) * 0.9235;
|
||||||
|
const selfEmploymentTax = seTaxBase * 0.153;
|
||||||
|
const taxableIncome = Math.max(0, netProfit - selfEmploymentTax / 2);
|
||||||
|
const federalEstimate = taxableIncome * 0.22;
|
||||||
|
const totalEstimated = selfEmploymentTax + federalEstimate;
|
||||||
|
|
||||||
|
const quarters = [1, 2, 3, 4].map((q) => {
|
||||||
|
const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2];
|
||||||
|
return {
|
||||||
|
label: `Q${q}`,
|
||||||
|
income: yearInvoices
|
||||||
|
.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth()))
|
||||||
|
.reduce((s, inv) => s + getSubtotal(inv), 0),
|
||||||
|
expenses: yearExpenses
|
||||||
|
.filter((exp) => qMonths.includes(new Date(exp.date).getMonth()))
|
||||||
|
.reduce((s, exp) => s + exp.amount, 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
grossIncome,
|
||||||
|
taxCollected,
|
||||||
|
totalInvoiced: grossIncome + taxCollected,
|
||||||
|
totalExpenses,
|
||||||
|
deductibleExpenses,
|
||||||
|
netProfit,
|
||||||
|
selfEmploymentTax,
|
||||||
|
federalEstimate,
|
||||||
|
totalEstimated,
|
||||||
|
quarters,
|
||||||
|
yearInvoices,
|
||||||
|
yearExpenses,
|
||||||
|
};
|
||||||
|
}, [invoices, expenses, taxYear]);
|
||||||
|
|
||||||
|
const availableYears = useMemo(() => {
|
||||||
|
const years = new Set<number>([currentYear, currentYear - 1]);
|
||||||
|
for (const inv of invoices)
|
||||||
|
years.add(new Date(inv.issueDate).getFullYear());
|
||||||
|
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
||||||
|
return Array.from(years).sort((a, b) => b - a);
|
||||||
|
}, [invoices, expenses, currentYear]);
|
||||||
|
|
||||||
|
const avgInvoice =
|
||||||
|
invoices.length > 0
|
||||||
|
? (overviewData?.totalRevenue ?? 0) /
|
||||||
|
(invoices.filter(
|
||||||
|
(i) =>
|
||||||
|
getEffectiveInvoiceStatus(
|
||||||
|
i.status as StoredInvoiceStatus,
|
||||||
|
i.dueDate,
|
||||||
|
) === "paid",
|
||||||
|
).length || 1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
function exportCSV() {
|
||||||
|
const rows: string[] = [
|
||||||
|
`Tax Year ${taxYear} - Income & Expense Report`,
|
||||||
|
`Generated: ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`,
|
||||||
|
"",
|
||||||
|
"INCOME (Paid Invoices)",
|
||||||
|
"Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total",
|
||||||
|
...taxData.yearInvoices.map((inv) => {
|
||||||
|
const subtotal = (inv.items ?? []).reduce(
|
||||||
|
(s, item) => s + item.amount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const fallbackSubtotal =
|
||||||
|
inv.totalAmount / (1 + (inv.taxRate ?? 0) / 100);
|
||||||
|
const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal;
|
||||||
|
const taxAmt = inv.totalAmount - invoiceSubtotal;
|
||||||
|
return [
|
||||||
|
new Date(inv.issueDate).toLocaleDateString("en-US"),
|
||||||
|
inv.invoiceNumber,
|
||||||
|
`"${inv.client?.name ?? ""}"`,
|
||||||
|
invoiceSubtotal.toFixed(2),
|
||||||
|
`${(inv.taxRate ?? 0).toFixed(1)}%`,
|
||||||
|
taxAmt.toFixed(2),
|
||||||
|
inv.totalAmount.toFixed(2),
|
||||||
|
].join(",");
|
||||||
|
}),
|
||||||
|
`,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`,
|
||||||
|
"",
|
||||||
|
"EXPENSES",
|
||||||
|
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
|
||||||
|
...taxData.yearExpenses.map((exp) =>
|
||||||
|
[
|
||||||
|
new Date(exp.date).toLocaleDateString("en-US"),
|
||||||
|
`"${exp.description}"`,
|
||||||
|
`"${exp.category ?? ""}"`,
|
||||||
|
exp.amount.toFixed(2),
|
||||||
|
exp.currency,
|
||||||
|
exp.billable ? "Yes" : "No",
|
||||||
|
exp.reimbursable ? "Yes" : "No",
|
||||||
|
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible
|
||||||
|
? "Yes"
|
||||||
|
: "No",
|
||||||
|
].join(","),
|
||||||
|
),
|
||||||
|
`,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`,
|
||||||
|
"",
|
||||||
|
"TAX SUMMARY",
|
||||||
|
`Gross Income,${taxData.grossIncome.toFixed(2)}`,
|
||||||
|
`Tax Collected,${taxData.taxCollected.toFixed(2)}`,
|
||||||
|
`Deductible Expenses,${taxData.deductibleExpenses.toFixed(2)}`,
|
||||||
|
`Net Profit,${taxData.netProfit.toFixed(2)}`,
|
||||||
|
`Est. Self-Employment Tax (15.3%),${taxData.selfEmploymentTax.toFixed(2)}`,
|
||||||
|
`Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`,
|
||||||
|
`Total Estimated Tax,${taxData.totalEstimated.toFixed(2)}`,
|
||||||
|
];
|
||||||
|
const blob = new Blob([rows.join("\n")], {
|
||||||
|
type: "text/csv;charset=utf-8;",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `tax-report-${taxYear}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="page-enter space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Reports"
|
||||||
|
description="Revenue and tax analytics"
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-enter space-y-6 pb-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Reports"
|
||||||
|
description="Revenue and tax analytics"
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="overview">
|
||||||
|
<TrendingUp className="mr-1.5 h-4 w-4" /> Overview
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tax">
|
||||||
|
<FileText className="mr-1.5 h-4 w-4" /> Tax Summary
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ── OVERVIEW TAB ── */}
|
||||||
|
<TabsContent value="overview" className="mt-4 space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-primary/10 rounded p-1.5">
|
||||||
|
<DollarSign className="text-primary h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Total Revenue
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">
|
||||||
|
{formatCurrency(overviewData?.totalRevenue ?? 0)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="rounded bg-yellow-500/10 p-1.5">
|
||||||
|
<Clock className="h-4 w-4 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Pending
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">
|
||||||
|
{formatCurrency(overviewData?.totalPending ?? 0)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="rounded bg-blue-500/10 p-1.5">
|
||||||
|
<TrendingUp className="h-4 w-4 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Avg Invoice
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">
|
||||||
|
{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="rounded bg-green-500/10 p-1.5">
|
||||||
|
<Users className="h-4 w-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Total Hours
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">
|
||||||
|
{(overviewData?.totalHours ?? 0).toFixed(1)}h
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-48 w-full md:h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={overviewData?.revenueByMonth ?? []}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="revenueGrad"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor="hsl(142, 76%, 36%)"
|
||||||
|
stopOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor="hsl(142, 76%, 36%)"
|
||||||
|
stopOpacity={0.02}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
className="stroke-border"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v: number) =>
|
||||||
|
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [
|
||||||
|
formatCurrency(toNumericChartValue(value)),
|
||||||
|
"Revenue",
|
||||||
|
]}
|
||||||
|
contentStyle={{
|
||||||
|
background: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="hsl(142, 76%, 36%)"
|
||||||
|
fill="url(#revenueGrad)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" /> Top Clients by Revenue
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!overviewData?.topClients.length ? (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
|
No paid invoices yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="h-48 md:h-56">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={overviewData.topClients}
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v: number) =>
|
||||||
|
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={80}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [
|
||||||
|
formatCurrency(toNumericChartValue(value)),
|
||||||
|
"Revenue",
|
||||||
|
]}
|
||||||
|
contentStyle={{
|
||||||
|
background: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="revenue"
|
||||||
|
fill="hsl(142, 76%, 36%)"
|
||||||
|
radius={[0, 4, 4, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Invoice Status Breakdown</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Object.entries(overviewData?.statusCount ?? {}).map(
|
||||||
|
([status, count]) => (
|
||||||
|
<div
|
||||||
|
key={status}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<StatusBadge status={status as never} />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${invoices.length ? (count / invoices.length) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground w-8 text-right text-sm">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{invoices.length === 0 && (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
|
No invoices yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Activity</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="divide-y">
|
||||||
|
{stats.recentInvoices.map((inv) => (
|
||||||
|
<div
|
||||||
|
key={inv.id}
|
||||||
|
className="flex items-center justify-between py-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{inv.client?.name ?? "—"}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{new Date(inv.issueDate).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusBadge
|
||||||
|
status={
|
||||||
|
getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
) as never
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{formatCurrency(inv.totalAmount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── TAX SUMMARY TAB ── */}
|
||||||
|
<TabsContent value="tax" className="mt-4 space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium">Tax Year</span>
|
||||||
|
<Select value={taxYear} onValueChange={setTaxYear}>
|
||||||
|
<SelectTrigger className="w-28">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableYears.map((y) => (
|
||||||
|
<SelectItem key={y} value={String(y)}>
|
||||||
|
{y}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={exportCSV} className="gap-2">
|
||||||
|
<Download className="h-4 w-4" /> Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Income */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-5 w-5" /> Income
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Gross Income (paid invoices)
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.grossIncome)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{taxData.taxCollected > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Tax Collected from Clients
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.taxCollected)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between font-medium">
|
||||||
|
<span>Total Invoiced (inc. tax)</span>
|
||||||
|
<span>{formatCurrency(taxData.totalInvoiced)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Expenses */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Receipt className="h-5 w-5" /> Expenses & Deductions
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Total Expenses</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.totalExpenses)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Tax-Deductible Expenses
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-green-600">
|
||||||
|
{formatCurrency(taxData.deductibleExpenses)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{taxData.totalExpenses > 0 &&
|
||||||
|
taxData.deductibleExpenses === 0 && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Mark expenses as "Tax Deductible" in the Expenses
|
||||||
|
page to include them here.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Estimated tax */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" /> Estimated Tax Liability
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Net Profit (income − deductible expenses)
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.netProfit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Self-Employment Tax (15.3% on 92.35% of net)
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.selfEmploymentTax)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Federal Income Tax (est. 22% bracket)
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.federalEstimate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between text-lg font-bold">
|
||||||
|
<span>Total Estimated Tax</span>
|
||||||
|
<span className="text-destructive">
|
||||||
|
{formatCurrency(taxData.totalEstimated)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground pt-1 text-xs">
|
||||||
|
Assumes US self-employment tax rules and the 22% federal
|
||||||
|
bracket. Consult a tax professional for accurate filing.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quarterly chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quarterly Breakdown</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-48 md:h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={taxData.quarters}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
className="stroke-border"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v: number) =>
|
||||||
|
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value, name) => [
|
||||||
|
formatCurrency(toNumericChartValue(value)),
|
||||||
|
name === "income" ? "Income" : "Expenses",
|
||||||
|
]}
|
||||||
|
contentStyle={{
|
||||||
|
background: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="income"
|
||||||
|
name="income"
|
||||||
|
fill="hsl(142, 76%, 36%)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="expenses"
|
||||||
|
name="expenses"
|
||||||
|
fill="hsl(0, 84%, 60%)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
opacity={0.75}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mt-2 flex justify-center gap-6 text-xs">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" />{" "}
|
||||||
|
Income
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" />{" "}
|
||||||
|
Expenses
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,12 @@ import {
|
|||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
|
Monitor,
|
||||||
|
PanelLeft,
|
||||||
|
Paintbrush,
|
||||||
|
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";
|
||||||
@@ -58,13 +63,118 @@ 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";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Switch } from "~/components/ui/switch";
|
import { Switch } from "~/components/ui/switch";
|
||||||
import { Slider } from "~/components/ui/slider";
|
import { Slider } from "~/components/ui/slider";
|
||||||
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||||
|
import {
|
||||||
|
bodyFontPreferences,
|
||||||
|
colorModes,
|
||||||
|
colorThemes,
|
||||||
|
type ColorTheme,
|
||||||
|
headingFontPreferences,
|
||||||
|
interfaceThemes,
|
||||||
|
radiusPreferences,
|
||||||
|
sidebarStyles,
|
||||||
|
themePresets,
|
||||||
|
type InterfaceTheme,
|
||||||
|
} 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) {
|
||||||
|
const [hue, saturation, lightness] =
|
||||||
|
channels?.match(/[\d.]+/g)?.map(Number) ?? [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
hue === undefined ||
|
||||||
|
saturation === undefined ||
|
||||||
|
lightness === undefined
|
||||||
|
) {
|
||||||
|
return "#16a34a";
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = saturation / 100;
|
||||||
|
const l = lightness / 100;
|
||||||
|
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||||
|
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||||
|
const m = l - c / 2;
|
||||||
|
const [r, g, b] =
|
||||||
|
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, g, b]
|
||||||
|
.map((channel) =>
|
||||||
|
Math.round((channel + m) * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0"),
|
||||||
|
)
|
||||||
|
.join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToHslChannels(hex: string) {
|
||||||
|
const normalized = hex.replace("#", "");
|
||||||
|
const red = parseInt(normalized.slice(0, 2), 16) / 255;
|
||||||
|
const green = parseInt(normalized.slice(2, 4), 16) / 255;
|
||||||
|
const blue = parseInt(normalized.slice(4, 6), 16) / 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 `0 0% ${Number((lightness * 100).toFixed(1))}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `${Number(((hue + 360) % 360).toFixed(1))} ${Number(
|
||||||
|
(saturation * 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();
|
||||||
@@ -83,6 +193,45 @@ export function SettingsContent() {
|
|||||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
const [isLinking, setIsLinking] = useState(false);
|
const [isLinking, setIsLinking] = useState(false);
|
||||||
|
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
||||||
|
const {
|
||||||
|
interfaceTheme,
|
||||||
|
bodyFontPreference,
|
||||||
|
headingFontPreference,
|
||||||
|
radiusPreference,
|
||||||
|
sidebarStyle,
|
||||||
|
colorMode,
|
||||||
|
colorTheme,
|
||||||
|
customColor,
|
||||||
|
brandName,
|
||||||
|
brandTagline,
|
||||||
|
brandLogoText,
|
||||||
|
brandIcon,
|
||||||
|
pdfTemplate,
|
||||||
|
pdfAccentColor,
|
||||||
|
pdfFooterText,
|
||||||
|
pdfShowLogo,
|
||||||
|
pdfShowPageNumbers,
|
||||||
|
updateAppearance,
|
||||||
|
updateAppearanceDebounced,
|
||||||
|
isUpdating: appearanceUpdating,
|
||||||
|
} = useAppearance();
|
||||||
|
const activePreset = themePresets[interfaceTheme];
|
||||||
|
const themeModified =
|
||||||
|
activePreset.bodyFontPreference !== bodyFontPreference ||
|
||||||
|
activePreset.headingFontPreference !== headingFontPreference ||
|
||||||
|
activePreset.colorTheme !== colorTheme ||
|
||||||
|
activePreset.radiusPreference !== radiusPreference ||
|
||||||
|
activePreset.sidebarStyle !== sidebarStyle ||
|
||||||
|
activePreset.pdfTemplate !== pdfTemplate ||
|
||||||
|
activePreset.pdfAccentColor !== pdfAccentColor;
|
||||||
|
const customColorValue = customColor ?? "142.1 76.2% 36.3%";
|
||||||
|
const selectAccent = (nextColorTheme: ColorTheme) => {
|
||||||
|
updateAppearance({
|
||||||
|
colorTheme: nextColorTheme,
|
||||||
|
...(nextColorTheme === "custom" ? { customColor: customColorValue } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleLinkAuthentik = async () => {
|
const handleLinkAuthentik = async () => {
|
||||||
setIsLinking(true);
|
setIsLinking(true);
|
||||||
@@ -91,7 +240,7 @@ export function SettingsContent() {
|
|||||||
providerId: "authentik",
|
providerId: "authentik",
|
||||||
callbackURL: "/dashboard/settings",
|
callbackURL: "/dashboard/settings",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error("Failed to link account");
|
toast.error("Failed to link account");
|
||||||
setIsLinking(false);
|
setIsLinking(false);
|
||||||
}
|
}
|
||||||
@@ -119,6 +268,7 @@ export function SettingsContent() {
|
|||||||
// Queries
|
// Queries
|
||||||
const { data: profile, refetch: refetchProfile } =
|
const { data: profile, refetch: refetchProfile } =
|
||||||
api.settings.getProfile.useQuery();
|
api.settings.getProfile.useQuery();
|
||||||
|
const isAdmin = profile?.role === "admin";
|
||||||
const { data: dataStats } = api.settings.getDataStats.useQuery();
|
const { data: dataStats } = api.settings.getDataStats.useQuery();
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
@@ -188,7 +338,6 @@ export function SettingsContent() {
|
|||||||
toast.error(`Delete failed: ${error.message}`);
|
toast.error(`Delete failed: ${error.message}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleUpdateProfile = (e: React.FormEvent) => {
|
const handleUpdateProfile = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
@@ -307,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) {
|
||||||
@@ -341,8 +491,8 @@ export function SettingsContent() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="general" className="space-y-4">
|
<Tabs defaultValue="general">
|
||||||
<TabsList className="bg-muted/50 grid w-full grid-cols-3 lg:w-[400px]">
|
<TabsList className="bg-muted/50 grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="general">General</TabsTrigger>
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
<TabsTrigger value="preferences">Preferences</TabsTrigger>
|
<TabsTrigger value="preferences">Preferences</TabsTrigger>
|
||||||
<TabsTrigger value="data">Data</TabsTrigger>
|
<TabsTrigger value="data">Data</TabsTrigger>
|
||||||
@@ -426,7 +576,9 @@ export function SettingsContent() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 p-0"
|
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 p-0"
|
||||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
onClick={() =>
|
||||||
|
setShowCurrentPassword(!showCurrentPassword)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{showCurrentPassword ? (
|
{showCurrentPassword ? (
|
||||||
<EyeOff className="h-4 w-4" />
|
<EyeOff className="h-4 w-4" />
|
||||||
@@ -481,7 +633,9 @@ export function SettingsContent() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 p-0"
|
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 p-0"
|
||||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
onClick={() =>
|
||||||
|
setShowConfirmPassword(!showConfirmPassword)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{showConfirmPassword ? (
|
{showConfirmPassword ? (
|
||||||
<EyeOff className="h-4 w-4" />
|
<EyeOff className="h-4 w-4" />
|
||||||
@@ -505,7 +659,7 @@ export function SettingsContent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Connected Accounts */}
|
{authentikEnabled && (
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center gap-2">
|
<CardTitle className="text-foreground flex items-center gap-2">
|
||||||
@@ -524,7 +678,9 @@ export function SettingsContent() {
|
|||||||
<Shield className="h-5 w-5 text-blue-500" />
|
<Shield className="h-5 w-5 text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-medium leading-none">Authentik SSO</p>
|
<p className="leading-none font-medium">
|
||||||
|
Authentik SSO
|
||||||
|
</p>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Connect your corporate account
|
Connect your corporate account
|
||||||
</p>
|
</p>
|
||||||
@@ -541,11 +697,585 @@ export function SettingsContent() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="preferences" className="space-y-8">
|
<TabsContent value="preferences" className="space-y-8">
|
||||||
{/* Theme follows system preferences automatically via CSS media queries */}
|
<Card className="bg-card border-border border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground flex items-center gap-2">
|
||||||
|
<Palette className="text-primary h-5 w-5" />
|
||||||
|
Appearance
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Select the app skin, color mode, accent, and font stack.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{!isAdmin ? (
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Platform appearance and branding are managed by an
|
||||||
|
administrator.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
) : (
|
||||||
|
<CardContent className="space-y-8">
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Brand</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Public-facing name, logo text, and short product tagline.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Brand Name</Label>
|
||||||
|
<Input
|
||||||
|
value={brandName}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateAppearanceDebounced({
|
||||||
|
brandName: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Logo Text</Label>
|
||||||
|
<Input
|
||||||
|
value={brandLogoText}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateAppearanceDebounced({
|
||||||
|
brandLogoText: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Brand Icon</Label>
|
||||||
|
<Input
|
||||||
|
value={brandIcon}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateAppearanceDebounced({
|
||||||
|
brandIcon: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tagline</Label>
|
||||||
|
<Input
|
||||||
|
value={brandTagline}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateAppearanceDebounced({
|
||||||
|
brandTagline: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4 border-t pt-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Theme</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Presets establish the broad visual language; color mode and
|
||||||
|
accent can still be tuned independently.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Paintbrush className="h-4 w-4" />
|
||||||
|
Theme Preset
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{themeModified && (
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
|
modified
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{themeModified && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => updateAppearance(activePreset)}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={interfaceTheme}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const nextTheme = value as InterfaceTheme;
|
||||||
|
updateAppearance(themePresets[nextTheme]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{interfaceThemes.map((themeOption) => (
|
||||||
|
<SelectItem
|
||||||
|
key={themeOption.value}
|
||||||
|
value={themeOption.value}
|
||||||
|
>
|
||||||
|
{themeOption.label}
|
||||||
|
{themeOption.value === interfaceTheme &&
|
||||||
|
themeModified
|
||||||
|
? " (modified)"
|
||||||
|
: ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs leading-snug">
|
||||||
|
Applies the theme, fonts, accent, corner radius,
|
||||||
|
navigation chrome, and PDF defaults.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs leading-snug">
|
||||||
|
{
|
||||||
|
interfaceThemes.find(
|
||||||
|
(themeOption) => themeOption.value === interfaceTheme,
|
||||||
|
)?.description
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Monitor className="h-4 w-4" />
|
||||||
|
Color Mode
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={colorMode}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateAppearance({
|
||||||
|
colorMode: value as typeof colorMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{colorModes.map((modeOption) => (
|
||||||
|
<SelectItem
|
||||||
|
key={modeOption.value}
|
||||||
|
value={modeOption.value}
|
||||||
|
>
|
||||||
|
{modeOption.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs leading-snug">
|
||||||
|
{
|
||||||
|
colorModes.find(
|
||||||
|
(modeOption) => modeOption.value === colorMode,
|
||||||
|
)?.description
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4 border-t pt-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Typography</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Body and heading fonts are separate so white-label installs
|
||||||
|
can feel native without losing hierarchy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Type className="h-4 w-4" />
|
||||||
|
Body Font
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={bodyFontPreference}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateAppearance({
|
||||||
|
bodyFontPreference:
|
||||||
|
value as typeof bodyFontPreference,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{bodyFontPreferences.map((fontOption) => (
|
||||||
|
<SelectItem
|
||||||
|
key={fontOption.value}
|
||||||
|
value={fontOption.value}
|
||||||
|
>
|
||||||
|
{fontOption.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs leading-snug">
|
||||||
|
{
|
||||||
|
bodyFontPreferences.find(
|
||||||
|
(fontOption) =>
|
||||||
|
fontOption.value === bodyFontPreference,
|
||||||
|
)?.description
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Type className="h-4 w-4" />
|
||||||
|
Heading Font
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={headingFontPreference}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateAppearance({
|
||||||
|
headingFontPreference:
|
||||||
|
value as typeof headingFontPreference,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{headingFontPreferences.map((fontOption) => (
|
||||||
|
<SelectItem
|
||||||
|
key={fontOption.value}
|
||||||
|
value={fontOption.value}
|
||||||
|
>
|
||||||
|
{fontOption.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs leading-snug">
|
||||||
|
{
|
||||||
|
headingFontPreferences.find(
|
||||||
|
(fontOption) =>
|
||||||
|
fontOption.value === headingFontPreference,
|
||||||
|
)?.description
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4 border-t pt-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Color</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Accent controls primary actions, focus rings, and branded
|
||||||
|
highlights.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Accent</Label>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
{colorThemes.map((themeOption) => (
|
||||||
|
<button
|
||||||
|
key={themeOption.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectAccent(themeOption.value)}
|
||||||
|
className={`border-border bg-background hover:bg-muted flex items-center gap-2 rounded-lg border p-2 text-left text-sm transition-colors ${
|
||||||
|
colorTheme === themeOption.value
|
||||||
|
? "border-primary bg-muted text-foreground"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="size-4 rounded-full border"
|
||||||
|
style={{ backgroundColor: themeOption.swatch }}
|
||||||
|
/>
|
||||||
|
{themeOption.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectAccent("custom")}
|
||||||
|
className={`border-border bg-background hover:bg-muted flex items-center gap-2 rounded-lg border p-2 text-left text-sm transition-colors ${
|
||||||
|
colorTheme === "custom"
|
||||||
|
? "border-primary bg-muted text-foreground"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="size-4 rounded-full border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: customColor
|
||||||
|
? `hsl(${customColor})`
|
||||||
|
: "hsl(142.1 76.2% 36.3%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{colorTheme === "custom" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<InputColor
|
||||||
|
label="Custom Accent"
|
||||||
|
value={hslChannelsToHex(customColorValue)}
|
||||||
|
onBlur={() => undefined}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (isFullHexColor(value)) {
|
||||||
|
updateAppearanceDebounced({
|
||||||
|
colorTheme: "custom",
|
||||||
|
customColor: hexToHslChannels(value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mt-0"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={customColorValue}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateAppearanceDebounced({
|
||||||
|
colorTheme: "custom",
|
||||||
|
customColor: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="142.1 76.2% 36.3%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-muted-foreground text-xs leading-snug">
|
||||||
|
Custom values use HSL channels, for example 142.1 76.2%
|
||||||
|
36.3%.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4 border-t pt-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Layout</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Control global rounding and whether navigation floats or
|
||||||
|
sits flush with the viewport.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Paintbrush className="h-4 w-4" />
|
||||||
|
Corner Radius
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={radiusPreference}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateAppearance({
|
||||||
|
radiusPreference: value as typeof radiusPreference,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{radiusPreferences.map((radiusOption) => (
|
||||||
|
<SelectItem
|
||||||
|
key={radiusOption.value}
|
||||||
|
value={radiusOption.value}
|
||||||
|
>
|
||||||
|
{radiusOption.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs leading-snug">
|
||||||
|
{
|
||||||
|
radiusPreferences.find(
|
||||||
|
(radiusOption) =>
|
||||||
|
radiusOption.value === radiusPreference,
|
||||||
|
)?.description
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<PanelLeft className="h-4 w-4" />
|
||||||
|
Navigation Chrome
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={sidebarStyle}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateAppearance({
|
||||||
|
sidebarStyle: value as typeof sidebarStyle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sidebarStyles.map((styleOption) => (
|
||||||
|
<SelectItem
|
||||||
|
key={styleOption.value}
|
||||||
|
value={styleOption.value}
|
||||||
|
>
|
||||||
|
{styleOption.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs leading-snug">
|
||||||
|
{
|
||||||
|
sidebarStyles.find(
|
||||||
|
(styleOption) => styleOption.value === sidebarStyle,
|
||||||
|
)?.description
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{appearanceUpdating && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Saving appearance...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<Card className="bg-card border-border border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground flex items-center gap-2">
|
||||||
|
<FileText className="text-primary h-5 w-5" />
|
||||||
|
Invoice Settings
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure generated invoice PDFs and preview the real document
|
||||||
|
output.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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">
|
||||||
@@ -556,13 +1286,16 @@ export function SettingsContent() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSaveAnimationPreferences} className="space-y-6">
|
<form
|
||||||
|
onSubmit={handleSaveAnimationPreferences}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Reduce Motion</Label>
|
<Label>Reduce Motion</Label>
|
||||||
<p className="text-muted-foreground text-xs leading-snug">
|
<p className="text-muted-foreground text-xs leading-snug">
|
||||||
Turn this on to reduce or remove non-essential animations and
|
Turn this on to reduce or remove non-essential animations
|
||||||
transitions.
|
and transitions.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -706,7 +1439,9 @@ export function SettingsContent() {
|
|||||||
className="w-full sm:flex-1"
|
className="w-full sm:flex-1"
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
{exportDataQuery.isFetching ? "Exporting..." : "Export Backup"}
|
{exportDataQuery.isFetching
|
||||||
|
? "Exporting..."
|
||||||
|
: "Export Backup"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -723,8 +1458,8 @@ export function SettingsContent() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Import Backup Data</DialogTitle>
|
<DialogTitle>Import Backup Data</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Upload your backup JSON file or paste the contents below.
|
Upload your backup JSON file or paste the contents
|
||||||
This will add the data to your existing account.
|
below. This will add the data to your existing account.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -759,7 +1494,9 @@ export function SettingsContent() {
|
|||||||
{/* File Upload Method */}
|
{/* File Upload Method */}
|
||||||
{importMethod === "file" && (
|
{importMethod === "file" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="backup-file">Select Backup File</Label>
|
<Label htmlFor="backup-file">
|
||||||
|
Select Backup File
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="backup-file"
|
id="backup-file"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -820,7 +1557,10 @@ export function SettingsContent() {
|
|||||||
{/* Backup Information */}
|
{/* Backup Information */}
|
||||||
<Collapsible>
|
<Collapsible>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button variant="ghost" className="w-full justify-between p-0">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-between p-0"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<span className="font-medium">Backup Information</span>
|
<span className="font-medium">Backup Information</span>
|
||||||
@@ -838,7 +1578,8 @@ export function SettingsContent() {
|
|||||||
• Backup files contain all data in secure JSON format
|
• Backup files contain all data in secure JSON format
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
• Import adds to existing data without replacing anything
|
• Import adds to existing data without replacing
|
||||||
|
anything
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
• Upload JSON files directly or paste content manually
|
• Upload JSON files directly or paste content manually
|
||||||
@@ -876,14 +1617,14 @@ export function SettingsContent() {
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will permanently delete your
|
This action cannot be undone. This will permanently delete
|
||||||
account and remove your data from our servers.
|
your account and remove your data from our servers.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<div className="my-4 space-y-2">
|
<div className="my-4 space-y-2">
|
||||||
<Label htmlFor="confirm-delete">
|
<Label htmlFor="confirm-delete">
|
||||||
Type <span className="font-bold">delete all my data</span> to
|
Type <span className="font-bold">delete all my data</span>{" "}
|
||||||
confirm
|
to confirm
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirm-delete"
|
id="confirm-delete"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { HydrateClient } from "~/trpc/server";
|
|||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
import { SettingsContent } from "./_components/settings-content";
|
import { SettingsContent } from "./_components/settings-content";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
@@ -14,15 +13,11 @@ export default async function SettingsPage() {
|
|||||||
variant="gradient"
|
variant="gradient"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
||||||
<SettingsContent />
|
<SettingsContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-19
@@ -1,36 +1,60 @@
|
|||||||
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";
|
||||||
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
|
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
|
||||||
|
import { AppearanceProvider } from "~/components/providers/appearance-provider";
|
||||||
|
import {
|
||||||
|
brand,
|
||||||
|
defaultBodyFontPreference,
|
||||||
|
defaultHeadingFontPreference,
|
||||||
|
defaultInterfaceTheme,
|
||||||
|
defaultRadiusPreference,
|
||||||
|
defaultSidebarStyle,
|
||||||
|
} from "~/lib/branding";
|
||||||
|
|
||||||
import { UmamiScript } from "~/components/analytics/umami-script";
|
import { UmamiScript } from "~/components/analytics/umami-script";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "beenvoice - Invoicing Made Simple",
|
title: `${brand.name} - Invoicing Made Simple`,
|
||||||
description:
|
description: brand.tagline,
|
||||||
"Simple and efficient invoicing for freelancers and small businesses",
|
|
||||||
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-sans",
|
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-heading",
|
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",
|
||||||
});
|
});
|
||||||
@@ -42,20 +66,59 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${inter.variable} ${playfair.variable} ${geistMono.variable}`}
|
data-interface-theme={defaultInterfaceTheme}
|
||||||
|
data-body-font={defaultBodyFontPreference}
|
||||||
|
data-heading-font={defaultHeadingFontPreference}
|
||||||
|
data-radius={defaultRadiusPreference}
|
||||||
|
data-sidebar-style={defaultSidebarStyle}
|
||||||
|
data-color-mode="system"
|
||||||
|
data-color-theme="slate"
|
||||||
|
className={`${geistSans.variable} ${playfair.variable} ${frutiger.variable} ${geistMono.variable}`}
|
||||||
>
|
>
|
||||||
|
<head>
|
||||||
|
<script
|
||||||
|
id="appearance-init"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
try {
|
||||||
|
var defaults = {
|
||||||
|
interfaceTheme: "${defaultInterfaceTheme}",
|
||||||
|
bodyFontPreference: "${defaultBodyFontPreference}",
|
||||||
|
headingFontPreference: "${defaultHeadingFontPreference}",
|
||||||
|
radiusPreference: "${defaultRadiusPreference}",
|
||||||
|
sidebarStyle: "${defaultSidebarStyle}",
|
||||||
|
colorMode: "system",
|
||||||
|
colorTheme: "slate"
|
||||||
|
};
|
||||||
|
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
|
||||||
|
var appearance = Object.assign(defaults, stored);
|
||||||
|
var root = document.documentElement;
|
||||||
|
root.dataset.interfaceTheme = appearance.interfaceTheme;
|
||||||
|
root.dataset.bodyFont = appearance.bodyFontPreference;
|
||||||
|
root.dataset.headingFont = appearance.headingFontPreference;
|
||||||
|
root.dataset.radius = appearance.radiusPreference;
|
||||||
|
root.dataset.sidebarStyle = appearance.sidebarStyle;
|
||||||
|
root.dataset.colorMode = appearance.colorMode;
|
||||||
|
root.dataset.colorTheme = appearance.colorTheme;
|
||||||
|
if (appearance.colorMode === "dark") root.classList.add("dark");
|
||||||
|
if (appearance.customColor) root.style.setProperty("--custom-primary", appearance.customColor);
|
||||||
|
} catch {}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
|
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
|
||||||
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
|
<div className="brand-background 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="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="w-[800px] h-[800px] bg-neutral-400/40 dark:bg-neutral-500/30 rounded-full blur-3xl animate-blob"></div>
|
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/40 blur-3xl dark:bg-neutral-500/30"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TRPCReactProvider>
|
<TRPCReactProvider>
|
||||||
|
<AppearanceProvider>
|
||||||
<AnimationPreferencesProvider>
|
<AnimationPreferencesProvider>
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</AnimationPreferencesProvider>
|
</AnimationPreferencesProvider>
|
||||||
|
</AppearanceProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<UmamiScript />
|
<UmamiScript />
|
||||||
</TRPCReactProvider>
|
</TRPCReactProvider>
|
||||||
|
|||||||
+71
-205
@@ -1,242 +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,
|
import { brand } from "~/lib/branding";
|
||||||
Zap,
|
|
||||||
Shield,
|
|
||||||
BarChart3,
|
|
||||||
Rocket,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const allowRegistration = env.DISABLE_SIGNUPS !== true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen relative 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="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
|
<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="w-[800px] h-[800px] bg-neutral-400/30 dark:bg-neutral-500/20 rounded-full blur-3xl animate-blob"></div>
|
<nav className="flex items-center gap-2">
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<nav className="fixed top-4 left-4 right-4 z-50 m-4 rounded-2xl border border-border/60 bg-background/80 backdrop-blur-md">
|
|
||||||
<div className="mx-auto px-6">
|
|
||||||
<div className="flex h-16 items-center justify-between">
|
|
||||||
<Logo />
|
|
||||||
<div className="hidden items-center space-x-8 md:flex">
|
|
||||||
<a
|
|
||||||
href="#features"
|
|
||||||
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Features
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#pricing"
|
|
||||||
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Pricing
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<Link href="/auth/signin">
|
<Link href="/auth/signin">
|
||||||
<Button
|
<Button variant="ghost" size="sm">
|
||||||
variant="ghost"
|
Sign in
|
||||||
size="sm"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
{allowRegistration && (
|
||||||
<Link href="/auth/register">
|
<Link href="/auth/register">
|
||||||
<Button size="sm" variant="default" className="rounded-xl px-6">
|
<Button size="sm">Create account</Button>
|
||||||
Get Started
|
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
{/* Hero Section */}
|
<section className="grid flex-1 items-center gap-10 py-14 md:grid-cols-[1fr_320px] md:py-20">
|
||||||
<section className="relative pt-48 pb-32">
|
<div className="max-w-2xl space-y-7">
|
||||||
<div className="container mx-auto px-4 text-center">
|
<div className="space-y-4">
|
||||||
<div className="mx-auto max-w-4xl">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
<Badge className="bg-primary/10 text-primary border-primary/20 mb-8 border px-4 py-1 text-sm rounded-full">
|
Personal invoicing
|
||||||
<Zap className="mr-2 h-3.5 w-3.5" />
|
</p>
|
||||||
Completely Free for Everyone
|
<h1 className="font-heading text-4xl leading-tight font-bold tracking-normal sm:text-5xl">
|
||||||
</Badge>
|
{brand.name} is a place to make and track invoices.
|
||||||
|
|
||||||
<h1 className="text-foreground mb-8 text-6xl font-heading font-bold tracking-tight sm:text-7xl lg:text-8xl leading-tight">
|
|
||||||
Invoicing Made <br />
|
|
||||||
<span className="text-primary italic">Beautifully Simple.</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="text-muted-foreground max-w-xl text-base leading-7 sm:text-lg">
|
||||||
<p className="text-muted-foreground mx-auto mb-12 max-w-2xl text-xl leading-relaxed font-sans">
|
Built for one person managing real clients, real work, and the
|
||||||
Create professional invoices, manage clients, and track payments with a tool that feels as good as it looks.
|
small admin loop around getting paid.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Link href="/auth/signin">
|
||||||
|
<Button size="lg" className="h-11 px-5">
|
||||||
|
Open workspace
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{allowRegistration && (
|
||||||
<Link href="/auth/register">
|
<Link href="/auth/register">
|
||||||
<Button
|
<Button variant="outline" size="lg" className="h-11 px-5">
|
||||||
size="lg"
|
Create account
|
||||||
className="h-14 px-10 text-lg rounded-2xl shadow-xl shadow-primary/20 hover:shadow-2xl hover:shadow-primary/30 transition-all duration-300"
|
|
||||||
>
|
|
||||||
Start For Free
|
|
||||||
<ArrowRight className="ml-2 h-5 w-5" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<a href="#features">
|
)}
|
||||||
<Button
|
</div>
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
className="h-14 px-10 text-lg rounded-2xl border-border/50 bg-background/50 hover:bg-background/80 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
Learn More
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 text-muted-foreground/80 flex flex-col items-center justify-center gap-2 text-sm sm:flex-row sm:gap-8">
|
<div className="border-border bg-card text-card-foreground rounded-xl border p-5 shadow-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-5">
|
||||||
<Check className="text-primary h-4 w-4" />
|
<div className="flex items-start gap-3">
|
||||||
<span>No credit card required</span>
|
<div className="bg-primary/10 text-primary rounded-md p-2">
|
||||||
|
<UserRound className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<Check className="text-primary h-4 w-4" />
|
<h2 className="text-sm font-semibold">Clients</h2>
|
||||||
<span>Setup in 2 minutes</span>
|
<p className="text-muted-foreground mt-1 text-sm leading-6">
|
||||||
|
Keep the people and businesses you invoice in one place.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<Check className="text-primary h-4 w-4" />
|
|
||||||
<span>Free forever</span>
|
<div className="flex items-start gap-3 border-t pt-5">
|
||||||
|
<div className="bg-primary/10 text-primary rounded-md p-2">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold">Invoices</h2>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm leading-6">
|
||||||
|
Draft, send, mark paid, and export the PDF when you need it.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Features Section */}
|
<footer className="text-muted-foreground flex flex-col gap-3 border-t py-5 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||||
<section id="features" className="py-24 relative">
|
<span>© 2026 {brand.name}</span>
|
||||||
<div className="container mx-auto px-4 relative z-10">
|
<div className="flex gap-5">
|
||||||
<div className="mb-20 text-center">
|
<Link href="/privacy" className="hover:text-foreground">
|
||||||
<h2 className="text-foreground mb-6 text-4xl font-heading font-bold sm:text-5xl">
|
Privacy
|
||||||
Everything you need to <span className="italic text-primary">thrive</span>
|
</Link>
|
||||||
</h2>
|
<Link href="/terms" className="hover:text-foreground">
|
||||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg">
|
Terms
|
||||||
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 hover:-translate-y-2 transition-transform duration-500 border-border/40 bg-background/60 backdrop-blur-xl">
|
|
||||||
<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 mb-4 text-2xl font-bold font-heading">
|
|
||||||
{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="flex items-center gap-3 text-sm text-foreground/80">
|
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Pricing Section */}
|
|
||||||
<section id="pricing" className="py-24 relative overflow-hidden">
|
|
||||||
<div className="container mx-auto px-4 relative z-10">
|
|
||||||
<div className="max-w-4xl mx-auto text-center mb-16">
|
|
||||||
<h2 className="text-5xl font-heading font-bold mb-6">Simple Pricing</h2>
|
|
||||||
<p className="text-xl text-muted-foreground">Focus on your work, not on fees.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-md mx-auto">
|
|
||||||
<Card className="relative overflow-visible border-primary/50 shadow-2xl shadow-primary/5 bg-background/80 backdrop-blur-xl">
|
|
||||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground px-6 py-1.5 rounded-full text-sm font-medium shadow-lg">
|
|
||||||
Forever Free
|
|
||||||
</div>
|
|
||||||
<CardContent className="p-10 text-center">
|
|
||||||
<div className="mb-2 text-6xl font-bold font-heading">$0</div>
|
|
||||||
<div className="text-muted-foreground mb-8">No credit card required.</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 mb-10 text-left pl-8">
|
|
||||||
{[
|
|
||||||
"Unlimited Invoices",
|
|
||||||
"Unlimited Clients",
|
|
||||||
"PDF Downloads",
|
|
||||||
"Payment Tracking",
|
|
||||||
"Email Support"
|
|
||||||
].map((item, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-3">
|
|
||||||
<Check className="h-5 w-5 text-primary shrink-0" />
|
|
||||||
<span className="text-foreground/90">{item}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link href="/auth/register" className="block">
|
|
||||||
<Button size="lg" className="w-full text-lg h-12 rounded-xl">
|
|
||||||
Get Started
|
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="border-t border-border/40 bg-background/50 backdrop-blur-sm py-12 mt-12">
|
|
||||||
<div className="container mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Logo size="sm" />
|
|
||||||
<span className="text-sm text-muted-foreground">© 2024 beenvoice</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-8 text-sm text-muted-foreground">
|
|
||||||
<a href="#" className="hover:text-foreground transition-colors">Privacy</a>
|
|
||||||
<a href="#" className="hover:text-foreground transition-colors">Terms</a>
|
|
||||||
<a href="#" className="hover:text-foreground transition-colors">Contact</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function AddressAutocomplete({
|
|||||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
||||||
/>
|
/>
|
||||||
{showSuggestions && suggestions.length > 0 && (
|
{showSuggestions && suggestions.length > 0 && (
|
||||||
<Card className="bg-card border-border border absolute z-10 mt-1 max-h-60 w-full overflow-auto">
|
<Card className="bg-card border-border absolute z-10 mt-1 max-h-60 w-full overflow-auto border">
|
||||||
<ul>
|
<ul>
|
||||||
{suggestions.map((s) => (
|
{suggestions.map((s) => (
|
||||||
<li
|
<li
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { brand } from "~/lib/branding";
|
||||||
|
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
@@ -9,7 +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 logoText = appearance.brandLogoText || brand.logoText;
|
||||||
|
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",
|
||||||
@@ -19,7 +38,16 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!animated) {
|
if (!animated) {
|
||||||
return <LogoContent className={className} size={size} sizeClasses={sizeClasses} />;
|
return (
|
||||||
|
<LogoContent
|
||||||
|
className={className}
|
||||||
|
size={size}
|
||||||
|
sizeClasses={sizeClasses}
|
||||||
|
logoPrefix={logoPrefix}
|
||||||
|
logoSuffix={logoSuffix}
|
||||||
|
icon={icon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +55,11 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.1, ease: "easeOut" }}
|
transition={{ duration: 0.1, ease: "easeOut" }}
|
||||||
className={cn("flex items-center font-mono", sizeClasses[size], className)}
|
className={cn(
|
||||||
|
"flex items-center font-mono",
|
||||||
|
sizeClasses[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<motion.span
|
<motion.span
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -35,7 +67,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
|||||||
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
|
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
|
||||||
className="text-primary font-bold tracking-tight"
|
className="text-primary font-bold tracking-tight"
|
||||||
>
|
>
|
||||||
$
|
{icon}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
{size !== "icon" && (
|
{size !== "icon" && (
|
||||||
<>
|
<>
|
||||||
@@ -51,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"
|
||||||
>
|
>
|
||||||
been
|
{logoPrefix}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
<motion.span
|
<motion.span
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -59,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"
|
||||||
>
|
>
|
||||||
voice
|
{logoSuffix}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -71,19 +103,35 @@ function LogoContent({
|
|||||||
className,
|
className,
|
||||||
size,
|
size,
|
||||||
sizeClasses,
|
sizeClasses,
|
||||||
|
logoPrefix,
|
||||||
|
logoSuffix,
|
||||||
|
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>;
|
||||||
|
logoPrefix: string;
|
||||||
|
logoSuffix: string;
|
||||||
|
icon: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center font-mono", sizeClasses[size], className)}>
|
<div
|
||||||
<span className="text-primary font-bold tracking-tight">$</span>
|
className={cn(
|
||||||
|
"flex items-center font-mono",
|
||||||
|
sizeClasses[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-primary font-bold tracking-tight">{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">been</span>
|
<span className="text-foreground font-bold tracking-tight">
|
||||||
<span className="text-foreground/70 font-bold tracking-tight">voice</span>
|
{logoPrefix}
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground/70 font-bold tracking-tight">
|
||||||
|
{logoSuffix}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -556,10 +556,7 @@ export function CSVImportPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{files.map((fileData, index) => (
|
{files.map((fileData, index) => (
|
||||||
<div
|
<div key={index} className="border-border bg-card border p-4">
|
||||||
key={index}
|
|
||||||
className="border-border bg-card border p-4"
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<FileText className="text-primary h-5 w-5" />
|
<FileText className="text-primary h-5 w-5" />
|
||||||
@@ -772,7 +769,7 @@ export function CSVImportPage() {
|
|||||||
|
|
||||||
{/* Preview Modal */}
|
{/* Preview Modal */}
|
||||||
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
|
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
|
||||||
<DialogContent className="bg-card border-border border flex max-h-[90vh] max-w-4xl flex-col">
|
<DialogContent className="bg-card border-border flex max-h-[90vh] max-w-4xl flex-col border">
|
||||||
<DialogHeader className="flex-shrink-0">
|
<DialogHeader className="flex-shrink-0">
|
||||||
<DialogTitle className="text-foreground flex items-center gap-2 text-xl font-bold">
|
<DialogTitle className="text-foreground flex items-center gap-2 text-xl font-bold">
|
||||||
<FileText className="text-primary h-5 w-5" />
|
<FileText className="text-primary h-5 w-5" />
|
||||||
|
|||||||
@@ -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[];
|
||||||
@@ -72,6 +81,11 @@ interface DataTableProps<TData, TValue> {
|
|||||||
options: { label: string; value: string }[];
|
options: { label: string; value: string }[];
|
||||||
}[];
|
}[];
|
||||||
onRowClick?: (row: TData) => void;
|
onRowClick?: (row: TData) => void;
|
||||||
|
/** Render bulk-action buttons when rows are selected. Receives selected rows and a clear function. */
|
||||||
|
selectionActions?: (
|
||||||
|
selectedRows: TData[],
|
||||||
|
clearSelection: () => void,
|
||||||
|
) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData, TValue>({
|
||||||
@@ -89,6 +103,7 @@ export function DataTable<TData, TValue>({
|
|||||||
actions,
|
actions,
|
||||||
filterableColumns = [],
|
filterableColumns = [],
|
||||||
onRowClick,
|
onRowClick,
|
||||||
|
selectionActions,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||||
@@ -119,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]);
|
||||||
@@ -335,6 +336,23 @@ export function DataTable<TData, TValue>({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Selection Toolbar */}
|
||||||
|
{selectionActions && table.getSelectedRowModel().rows.length > 0 && (
|
||||||
|
<Card className="bg-primary/5 border-primary/20 border py-2">
|
||||||
|
<CardContent className="flex items-center justify-between gap-3 px-3 py-0">
|
||||||
|
<span className="text-foreground text-sm font-medium">
|
||||||
|
{table.getSelectedRowModel().rows.length} selected
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectionActions(
|
||||||
|
table.getSelectedRowModel().rows.map((r) => r.original),
|
||||||
|
() => table.resetRowSelection(),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table Content Card */}
|
{/* Table Content Card */}
|
||||||
<Card className="bg-card border-border overflow-hidden border p-0">
|
<Card className="bg-card border-border overflow-hidden border p-0">
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto">
|
||||||
@@ -346,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}
|
||||||
@@ -384,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}
|
||||||
@@ -428,7 +442,8 @@ export function DataTable<TData, TValue>({
|
|||||||
<p className="text-muted-foreground hidden text-xs sm:inline sm:text-sm">
|
<p className="text-muted-foreground hidden text-xs sm:inline sm:text-sm">
|
||||||
{table.getFilteredRowModel().rows.length === 0
|
{table.getFilteredRowModel().rows.length === 0
|
||||||
? "No entries"
|
? "No entries"
|
||||||
: `Showing ${table.getState().pagination.pageIndex *
|
: `Showing ${
|
||||||
|
table.getState().pagination.pageIndex *
|
||||||
table.getState().pagination.pageSize +
|
table.getState().pagination.pageSize +
|
||||||
1
|
1
|
||||||
} to ${Math.min(
|
} to ${Math.min(
|
||||||
@@ -440,7 +455,8 @@ export function DataTable<TData, TValue>({
|
|||||||
<p className="text-muted-foreground text-xs sm:hidden">
|
<p className="text-muted-foreground text-xs sm:hidden">
|
||||||
{table.getFilteredRowModel().rows.length === 0
|
{table.getFilteredRowModel().rows.length === 0
|
||||||
? "0"
|
? "0"
|
||||||
: `${table.getState().pagination.pageIndex *
|
: `${
|
||||||
|
table.getState().pagination.pageIndex *
|
||||||
table.getState().pagination.pageSize +
|
table.getState().pagination.pageSize +
|
||||||
1
|
1
|
||||||
}-${Math.min(
|
}-${Math.min(
|
||||||
@@ -471,7 +487,7 @@ export function DataTable<TData, TValue>({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-10 w-10 md:h-8 md:w-8"
|
||||||
onClick={() => table.setPageIndex(0)}
|
onClick={() => table.setPageIndex(0)}
|
||||||
disabled={!table.getCanPreviousPage()}
|
disabled={!table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
@@ -481,7 +497,7 @@ export function DataTable<TData, TValue>({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-10 w-10 md:h-8 md:w-8"
|
||||||
onClick={() => table.previousPage()}
|
onClick={() => table.previousPage()}
|
||||||
disabled={!table.getCanPreviousPage()}
|
disabled={!table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
@@ -503,7 +519,7 @@ export function DataTable<TData, TValue>({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-10 w-10 md:h-8 md:w-8"
|
||||||
onClick={() => table.nextPage()}
|
onClick={() => table.nextPage()}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={!table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
@@ -513,7 +529,7 @@ export function DataTable<TData, TValue>({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-10 w-10 md:h-8 md:w-8"
|
||||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={!table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ function SortableItem({
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={`card-secondary transition-colors ${isDragging ? "opacity-50 shadow-lg" : ""
|
className={`card-secondary transition-colors ${
|
||||||
|
isDragging ? "opacity-50 shadow-lg" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Desktop Layout - Hidden on Mobile */}
|
{/* Desktop Layout - Hidden on Mobile */}
|
||||||
@@ -360,10 +361,7 @@ export function EditableInvoiceItems({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{items.map((item, _index) => (
|
{items.map((item, _index) => (
|
||||||
<div
|
<div key={item.id} className="card-secondary animate-pulse p-4">
|
||||||
key={item.id}
|
|
||||||
className="card-secondary animate-pulse p-4"
|
|
||||||
>
|
|
||||||
{/* Desktop Skeleton */}
|
{/* Desktop Skeleton */}
|
||||||
<div className="hidden grid-cols-12 gap-3 md:grid">
|
<div className="hidden grid-cols-12 gap-3 md:grid">
|
||||||
<div className="col-span-1">
|
<div className="col-span-1">
|
||||||
|
|||||||
@@ -1,501 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
|
|
||||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
|
||||||
import { Separator } from "~/components/ui/separator";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "~/components/ui/dialog";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import {
|
|
||||||
FileText,
|
|
||||||
User,
|
|
||||||
DollarSign,
|
|
||||||
Trash2,
|
|
||||||
Download,
|
|
||||||
Send,
|
|
||||||
Clock,
|
|
||||||
MapPin,
|
|
||||||
Mail,
|
|
||||||
Phone,
|
|
||||||
AlertCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { generateInvoicePDF } from "~/lib/pdf-export";
|
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
|
||||||
|
|
||||||
interface InvoiceViewProps {
|
|
||||||
invoiceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusIconConfig = {
|
|
||||||
draft: FileText,
|
|
||||||
sent: Send,
|
|
||||||
paid: DollarSign,
|
|
||||||
overdue: AlertCircle,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
||||||
const [isExportingPDF, setIsExportingPDF] = useState(false);
|
|
||||||
|
|
||||||
// Fetch invoice data
|
|
||||||
const {
|
|
||||||
data: invoice,
|
|
||||||
isLoading,
|
|
||||||
refetch,
|
|
||||||
} = api.invoices.getById.useQuery({ id: invoiceId });
|
|
||||||
|
|
||||||
// Delete mutation
|
|
||||||
const deleteInvoice = api.invoices.delete.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Invoice deleted successfully");
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
router.push("/dashboard/invoices");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message ?? "Failed to delete invoice");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update status mutation
|
|
||||||
const updateStatus = api.invoices.updateStatus.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Status updated successfully");
|
|
||||||
void refetch();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message ?? "Failed to update status");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
setDeleteDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDelete = () => {
|
|
||||||
deleteInvoice.mutate({ id: invoiceId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusUpdate = (newStatus: "draft" | "sent" | "paid") => {
|
|
||||||
updateStatus.mutate({ id: invoiceId, status: newStatus });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePDFExport = async () => {
|
|
||||||
if (!invoice) return;
|
|
||||||
|
|
||||||
setIsExportingPDF(true);
|
|
||||||
try {
|
|
||||||
await generateInvoicePDF(invoice);
|
|
||||||
toast.success("PDF exported successfully");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("PDF export error:", error);
|
|
||||||
toast.error("Failed to export PDF. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setIsExportingPDF(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
|
||||||
return format(new Date(date), "MMM dd, yyyy");
|
|
||||||
};
|
|
||||||
|
|
||||||
const isOverdue =
|
|
||||||
invoice &&
|
|
||||||
new Date(invoice.dueDate) < new Date() &&
|
|
||||||
invoice.status !== "paid";
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
<Skeleton className="h-4 w-1/2" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!invoice) {
|
|
||||||
return (
|
|
||||||
<div className="py-12 text-center">
|
|
||||||
<FileText className="text-muted mx-auto mb-4 h-12 w-12" />
|
|
||||||
<h3 className="text-foreground mb-2 text-lg font-medium">
|
|
||||||
Invoice not found
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted mb-4">
|
|
||||||
The invoice you're looking for doesn't exist or has been
|
|
||||||
deleted.
|
|
||||||
</p>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/dashboard/invoices">Back to Invoices</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatusIcon =
|
|
||||||
statusIconConfig[invoice.status as keyof typeof statusIconConfig];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Status Alert */}
|
|
||||||
{isOverdue && (
|
|
||||||
<Card className="border-destructive/20 bg-destructive/10">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="text-destructive flex items-center gap-2">
|
|
||||||
<AlertCircle className="h-5 w-5" />
|
|
||||||
<span className="font-medium">This invoice is overdue</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="space-y-6 lg:col-span-2">
|
|
||||||
{/* Invoice Header Card */}
|
|
||||||
<Card className="bg-card border-border border">
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="bg-primary/10 p-2">
|
|
||||||
<FileText className="text-primary h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-foreground text-2xl font-bold">
|
|
||||||
{invoice.invoiceNumber}
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Professional Invoice
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-6 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Issue Date</span>
|
|
||||||
<p className="text-foreground font-medium">
|
|
||||||
{formatDate(invoice.issueDate)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Due Date</span>
|
|
||||||
<p className="text-foreground font-medium">
|
|
||||||
{formatDate(invoice.dueDate)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 text-right">
|
|
||||||
<StatusBadge
|
|
||||||
status={invoice.status as StatusType}
|
|
||||||
className="px-3 py-1 text-sm font-medium"
|
|
||||||
>
|
|
||||||
<StatusIcon className="mr-1 h-3 w-3" />
|
|
||||||
</StatusBadge>
|
|
||||||
<div className="text-primary text-3xl font-bold">
|
|
||||||
{formatCurrency(invoice.totalAmount)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handlePDFExport}
|
|
||||||
disabled={isExportingPDF}
|
|
||||||
variant="default"
|
|
||||||
className="transform-none"
|
|
||||||
>
|
|
||||||
{isExportingPDF ? (
|
|
||||||
<>
|
|
||||||
<div className="mr-2 h-4 w-4 animate-spin border-2 border-white border-t-transparent" />
|
|
||||||
Generating PDF...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Download PDF
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Client Information */}
|
|
||||||
<Card className="bg-card border-border border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-primary flex items-center gap-2">
|
|
||||||
<User className="h-5 w-5" />
|
|
||||||
Bill To
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-foreground text-lg font-semibold">
|
|
||||||
{invoice.client?.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
|
|
||||||
{invoice.client?.email && (
|
|
||||||
<div className="text-muted-foreground flex items-center gap-2">
|
|
||||||
<Mail className="text-muted-foreground h-4 w-4" />
|
|
||||||
{invoice.client.email}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{invoice.client?.phone && (
|
|
||||||
<div className="text-muted-foreground flex items-center gap-2">
|
|
||||||
<Phone className="text-muted-foreground h-4 w-4" />
|
|
||||||
{invoice.client.phone}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(invoice.client?.addressLine1 ??
|
|
||||||
invoice.client?.city ??
|
|
||||||
invoice.client?.state) && (
|
|
||||||
<div className="text-muted-foreground flex items-start gap-2 md:col-span-2">
|
|
||||||
<MapPin className="text-muted-foreground mt-0.5 h-4 w-4 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
{invoice.client?.addressLine1 && (
|
|
||||||
<div>{invoice.client.addressLine1}</div>
|
|
||||||
)}
|
|
||||||
{invoice.client?.addressLine2 && (
|
|
||||||
<div>{invoice.client.addressLine2}</div>
|
|
||||||
)}
|
|
||||||
{(invoice.client?.city ??
|
|
||||||
invoice.client?.state ??
|
|
||||||
invoice.client?.postalCode) && (
|
|
||||||
<div>
|
|
||||||
{[
|
|
||||||
invoice.client?.city,
|
|
||||||
invoice.client?.state,
|
|
||||||
invoice.client?.postalCode,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{invoice.client?.country && (
|
|
||||||
<div>{invoice.client.country}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Invoice Items */}
|
|
||||||
<Card className="bg-secondary border-border border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-primary flex items-center gap-2">
|
|
||||||
<Clock className="h-5 w-5" />
|
|
||||||
Invoice Items
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{invoice.items?.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={item.id || index}
|
|
||||||
className="bg-background flex items-center justify-between rounded-lg p-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
{formatDate(item.date)}
|
|
||||||
</div>
|
|
||||||
<div className="text-foreground font-medium">
|
|
||||||
{item.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-foreground text-right font-medium">
|
|
||||||
{formatCurrency(item.amount)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
{invoice.notes && (
|
|
||||||
<Card className="bg-card border-border border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-primary">Notes</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-muted-foreground whitespace-pre-wrap">
|
|
||||||
{invoice.notes}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Status Actions */}
|
|
||||||
<Card className="bg-secondary border-border border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-primary">Status Actions</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{invoice.status === "draft" && (
|
|
||||||
<Button
|
|
||||||
onClick={() => handleStatusUpdate("sent")}
|
|
||||||
disabled={updateStatus.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Send className="mr-2 h-4 w-4" />
|
|
||||||
Mark as Sent
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{invoice.status === "sent" && (
|
|
||||||
<Button
|
|
||||||
onClick={() => handleStatusUpdate("paid")}
|
|
||||||
disabled={updateStatus.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<DollarSign className="mr-2 h-4 w-4" />
|
|
||||||
Mark as Paid
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{invoice.status === "overdue" && (
|
|
||||||
<Button
|
|
||||||
onClick={() => handleStatusUpdate("paid")}
|
|
||||||
disabled={updateStatus.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<DollarSign className="mr-2 h-4 w-4" />
|
|
||||||
Mark as Paid
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{invoice.status === "paid" && (
|
|
||||||
<div className="py-4 text-center">
|
|
||||||
<DollarSign className="text-primary mx-auto mb-2 h-8 w-8" />
|
|
||||||
<p className="text-primary font-medium">Invoice Paid</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Invoice Summary */}
|
|
||||||
<Card className="bg-card border-border border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-primary">Summary</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Subtotal</span>
|
|
||||||
<span className="text-foreground font-medium">
|
|
||||||
{formatCurrency(invoice.totalAmount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Tax</span>
|
|
||||||
<span className="text-foreground font-medium">$0.00</span>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="flex justify-between text-lg font-bold">
|
|
||||||
<span className="text-foreground">Total</span>
|
|
||||||
<span className="text-primary">
|
|
||||||
{formatCurrency(invoice.totalAmount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-border border-t pt-4 text-center">
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{invoice.items?.length ?? 0} item
|
|
||||||
{invoice.items?.length !== 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Danger Zone */}
|
|
||||||
<Card className="bg-card border-destructive/20 border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button
|
|
||||||
onClick={handleDelete}
|
|
||||||
variant="destructive"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete Invoice
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
||||||
<DialogContent className="bg-card border-border border">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-foreground text-xl font-bold">
|
|
||||||
Delete Invoice
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-muted-foreground">
|
|
||||||
Are you sure you want to delete this invoice? This action cannot
|
|
||||||
be undone and will permanently remove the invoice and all its
|
|
||||||
data.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeleteDialogOpen(false)}
|
|
||||||
className="border-border text-muted-foreground hover:bg-muted"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={confirmDelete}
|
|
||||||
disabled={deleteInvoice.isPending}
|
|
||||||
className="bg-destructive hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 ?? "",
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ import {
|
|||||||
VALIDATION_MESSAGES,
|
VALIDATION_MESSAGES,
|
||||||
PLACEHOLDERS,
|
PLACEHOLDERS,
|
||||||
} from "~/lib/form-constants";
|
} from "~/lib/form-constants";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
|
|
||||||
interface ClientFormProps {
|
interface ClientFormProps {
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
@@ -45,6 +53,7 @@ interface FormData {
|
|||||||
postalCode: string;
|
postalCode: string;
|
||||||
country: string;
|
country: string;
|
||||||
defaultHourlyRate: number | null;
|
defaultHourlyRate: number | null;
|
||||||
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormErrors {
|
interface FormErrors {
|
||||||
@@ -70,6 +79,7 @@ const initialFormData: FormData = {
|
|||||||
postalCode: "",
|
postalCode: "",
|
||||||
country: "United States",
|
country: "United States",
|
||||||
defaultHourlyRate: null,
|
defaultHourlyRate: null,
|
||||||
|
currency: "USD",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ClientForm({ clientId, mode }: ClientFormProps) {
|
export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||||
@@ -109,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 ?? "",
|
||||||
@@ -120,6 +131,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
postalCode: client.postalCode ?? "",
|
postalCode: client.postalCode ?? "",
|
||||||
country: client.country ?? "United States",
|
country: client.country ?? "United States",
|
||||||
defaultHourlyRate: client.defaultHourlyRate ?? null,
|
defaultHourlyRate: client.defaultHourlyRate ?? null,
|
||||||
|
currency: client.currency ?? "USD",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [client, mode]);
|
}, [client, mode]);
|
||||||
@@ -468,6 +480,30 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="currency" className="text-sm font-medium">
|
||||||
|
Currency
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground mb-2 text-xs">
|
||||||
|
Default currency for invoices created for this client.
|
||||||
|
</p>
|
||||||
|
<Select
|
||||||
|
value={formData.currency}
|
||||||
|
onValueChange={(v) => handleInputChange("currency", v)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SUPPORTED_CURRENCIES.map((c) => (
|
||||||
|
<SelectItem key={c.code} value={c.code}>
|
||||||
|
{c.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function EmailComposer({
|
|||||||
content: customMessage,
|
content: customMessage,
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
onCustomMessageChange?.(editor.getHTML());
|
onCustomMessageChange?.(editor.isEmpty ? "" : editor.getHTML());
|
||||||
},
|
},
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -109,7 +109,7 @@ export function EmailComposer({
|
|||||||
// Update editor content when customMessage prop changes
|
// Update editor content when customMessage prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editor && customMessage !== undefined) {
|
if (editor && customMessage !== undefined) {
|
||||||
const currentContent = editor.getHTML();
|
const currentContent = editor.isEmpty ? "" : editor.getHTML();
|
||||||
if (currentContent !== customMessage) {
|
if (currentContent !== customMessage) {
|
||||||
editor.commands.setContent(customMessage);
|
editor.commands.setContent(customMessage);
|
||||||
}
|
}
|
||||||
@@ -222,11 +222,10 @@ export function EmailComposer({
|
|||||||
{onCustomMessageChange && (
|
{onCustomMessageChange && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm font-medium">
|
<Label className="text-sm font-medium">Email Note (Optional)</Label>
|
||||||
Custom Message (Optional)
|
|
||||||
</Label>
|
|
||||||
<p className="text-muted-foreground mb-2 text-xs">
|
<p className="text-muted-foreground mb-2 text-xs">
|
||||||
This message will appear between the greeting and invoice summary
|
This appears only in the email body and is not added to the
|
||||||
|
invoice PDF.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface EmailPreviewProps {
|
|||||||
taxRate: number;
|
taxRate: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
totalAmount?: number;
|
totalAmount?: number;
|
||||||
|
currency?: string | null;
|
||||||
client?: {
|
client?: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -27,8 +28,11 @@ interface EmailPreviewProps {
|
|||||||
};
|
};
|
||||||
items?: Array<{
|
items?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
date?: Date;
|
||||||
|
description?: string;
|
||||||
hours: number;
|
hours: number;
|
||||||
rate: number;
|
rate: number;
|
||||||
|
amount?: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -66,7 +70,7 @@ export function EmailPreview({
|
|||||||
status: invoice.status ?? "draft",
|
status: invoice.status ?? "draft",
|
||||||
totalAmount: invoice.totalAmount ?? calculateTotal(),
|
totalAmount: invoice.totalAmount ?? calculateTotal(),
|
||||||
taxRate: invoice.taxRate,
|
taxRate: invoice.taxRate,
|
||||||
notes: null,
|
currency: invoice.currency,
|
||||||
client: {
|
client: {
|
||||||
name: invoice.client?.name ?? "Client",
|
name: invoice.client?.name ?? "Client",
|
||||||
email: invoice.client?.email ?? null,
|
email: invoice.client?.email ?? null,
|
||||||
@@ -74,11 +78,11 @@ export function EmailPreview({
|
|||||||
business: invoice.business ?? null,
|
business: invoice.business ?? null,
|
||||||
items:
|
items:
|
||||||
invoice.items?.map((item) => ({
|
invoice.items?.map((item) => ({
|
||||||
date: new Date(),
|
date: item.date ?? new Date(),
|
||||||
description: "Service",
|
description: item.description ?? "Service",
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
amount: item.hours * item.rate,
|
amount: item.amount ?? item.hours * item.rate,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
},
|
},
|
||||||
customContent: content,
|
customContent: content,
|
||||||
@@ -142,7 +146,7 @@ export function EmailPreview({
|
|||||||
|
|
||||||
{/* Email Content */}
|
{/* Email Content */}
|
||||||
{emailTemplate ? (
|
{emailTemplate ? (
|
||||||
<div className=" border bg-gray-50 p-1 shadow-sm">
|
<div className="border bg-gray-50 p-1 shadow-sm">
|
||||||
<iframe
|
<iframe
|
||||||
srcDoc={emailTemplate.html}
|
srcDoc={emailTemplate.html}
|
||||||
className="h-[700px] w-full rounded border-0"
|
className="h-[700px] w-full rounded border-0"
|
||||||
|
|||||||
@@ -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",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { format, startOfWeek, endOfWeek, eachDayOfInterval, isSameDay, subWeeks, addWeeks, subMonths, addMonths } from "date-fns";
|
import {
|
||||||
|
format,
|
||||||
|
startOfWeek,
|
||||||
|
endOfWeek,
|
||||||
|
eachDayOfInterval,
|
||||||
|
isSameDay,
|
||||||
|
subWeeks,
|
||||||
|
addWeeks,
|
||||||
|
subMonths,
|
||||||
|
addMonths,
|
||||||
|
} from "date-fns";
|
||||||
import { Calendar } from "~/components/ui/calendar";
|
import { Calendar } from "~/components/ui/calendar";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -14,10 +24,16 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import { Plus, Trash2, Clock, Calendar as CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
|
import {
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Clock,
|
||||||
|
Calendar as CalendarIcon,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
|
||||||
interface InvoiceItem {
|
interface InvoiceItem {
|
||||||
id: string;
|
id: string;
|
||||||
date: Date;
|
date: Date;
|
||||||
@@ -32,7 +48,7 @@ interface InvoiceCalendarViewProps {
|
|||||||
onUpdateItem: (
|
onUpdateItem: (
|
||||||
index: number,
|
index: number,
|
||||||
field: string,
|
field: string,
|
||||||
value: string | number | Date
|
value: string | number | Date,
|
||||||
) => void;
|
) => void;
|
||||||
onAddItem: (date?: Date) => void;
|
onAddItem: (date?: Date) => void;
|
||||||
onRemoveItem: (index: number) => void;
|
onRemoveItem: (index: number) => void;
|
||||||
@@ -64,14 +80,17 @@ export function InvoiceCalendarView({
|
|||||||
}, [items, date]);
|
}, [items, date]);
|
||||||
|
|
||||||
// Helper to get items for any date (for calendar view)
|
// Helper to get items for any date (for calendar view)
|
||||||
const getItemsForDate = React.useCallback((targetDate: Date) => {
|
const getItemsForDate = React.useCallback(
|
||||||
|
(targetDate: Date) => {
|
||||||
return items
|
return items
|
||||||
.map((item, index) => ({ item, index }))
|
.map((item, index) => ({ item, index }))
|
||||||
.filter((wrapper) => {
|
.filter((wrapper) => {
|
||||||
const itemDate = new Date(wrapper.item.date);
|
const itemDate = new Date(wrapper.item.date);
|
||||||
return isSameDay(itemDate, targetDate);
|
return isSameDay(itemDate, targetDate);
|
||||||
});
|
});
|
||||||
}, [items]);
|
},
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSelectDate = (newDate: Date | undefined) => {
|
const handleSelectDate = (newDate: Date | undefined) => {
|
||||||
if (!newDate) return;
|
if (!newDate) return;
|
||||||
@@ -88,7 +107,10 @@ export function InvoiceCalendarView({
|
|||||||
// Week View Logic - Uses viewDate
|
// Week View Logic - Uses viewDate
|
||||||
const currentWeekStart = startOfWeek(viewDate);
|
const currentWeekStart = startOfWeek(viewDate);
|
||||||
const currentWeekEnd = endOfWeek(viewDate);
|
const currentWeekEnd = endOfWeek(viewDate);
|
||||||
const weekDays = eachDayOfInterval({ start: currentWeekStart, end: currentWeekEnd });
|
const weekDays = eachDayOfInterval({
|
||||||
|
start: currentWeekStart,
|
||||||
|
end: currentWeekEnd,
|
||||||
|
});
|
||||||
|
|
||||||
const handleCloseSheet = (isOpen: boolean) => {
|
const handleCloseSheet = (isOpen: boolean) => {
|
||||||
setSheetOpen(isOpen);
|
setSheetOpen(isOpen);
|
||||||
@@ -98,51 +120,81 @@ export function InvoiceCalendarView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-4 h-full w-full", className)}>
|
<div className={cn("flex h-full w-full flex-col gap-4", className)}>
|
||||||
<div className="flex items-center justify-between px-4 pt-4 w-full gap-4">
|
<div className="flex w-full items-center justify-between gap-4 px-4 pt-4">
|
||||||
{/* Navigation Controls */}
|
{/* Navigation Controls */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{view === "week" ? (
|
{view === "week" ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="icon" onClick={() => setViewDate(d => subWeeks(d, 1))} className="h-8 w-8 rounded-lg">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setViewDate((d) => subWeeks(d, 1))}
|
||||||
|
className="h-8 w-8 rounded-lg"
|
||||||
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm font-medium w-36 text-center">
|
<span className="w-36 text-center text-sm font-medium">
|
||||||
{`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`}
|
{`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="outline" size="icon" onClick={() => setViewDate(d => addWeeks(d, 1))} className="h-8 w-8 rounded-lg">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setViewDate((d) => addWeeks(d, 1))}
|
||||||
|
className="h-8 w-8 rounded-lg"
|
||||||
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="icon" onClick={() => setViewDate(d => subMonths(d, 1))} className="h-8 w-8 rounded-lg">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setViewDate((d) => subMonths(d, 1))}
|
||||||
|
className="h-8 w-8 rounded-lg"
|
||||||
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm font-medium w-36 text-center">
|
<span className="w-36 text-center text-sm font-medium">
|
||||||
{format(viewDate, "MMMM yyyy")}
|
{format(viewDate, "MMMM yyyy")}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="outline" size="icon" onClick={() => setViewDate(d => addMonths(d, 1))} className="h-8 w-8 rounded-lg">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setViewDate((d) => addMonths(d, 1))}
|
||||||
|
className="h-8 w-8 rounded-lg"
|
||||||
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 ml-auto">
|
<div className="ml-auto flex items-center space-x-2">
|
||||||
{/* View Switcher */}
|
{/* View Switcher */}
|
||||||
<div className="bg-muted p-1 rounded-lg flex text-sm">
|
<div className="bg-muted flex rounded-lg p-1 text-sm">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setView("month")}
|
onClick={() => setView("month")}
|
||||||
className={cn("px-3 py-1.5 rounded-md transition-all text-center font-medium", view === "month" ? "bg-background shadow text-foreground" : "text-muted-foreground hover:text-foreground")}
|
className={cn(
|
||||||
|
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||||
|
view === "month"
|
||||||
|
? "bg-background text-foreground shadow"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Month
|
Month
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setView("week")}
|
onClick={() => setView("week")}
|
||||||
className={cn("px-3 py-1.5 rounded-md transition-all text-center font-medium", view === "week" ? "bg-background shadow text-foreground" : "text-muted-foreground hover:text-foreground")}
|
className={cn(
|
||||||
|
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||||
|
view === "week"
|
||||||
|
? "bg-background text-foreground shadow"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Week
|
Week
|
||||||
</button>
|
</button>
|
||||||
@@ -150,7 +202,7 @@ export function InvoiceCalendarView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 w-full overflow-hidden">
|
<div className="w-full flex-1 overflow-hidden">
|
||||||
{view === "month" ? (
|
{view === "month" ? (
|
||||||
<Calendar
|
<Calendar
|
||||||
mode="single"
|
mode="single"
|
||||||
@@ -158,7 +210,7 @@ export function InvoiceCalendarView({
|
|||||||
onSelect={handleSelectDate}
|
onSelect={handleSelectDate}
|
||||||
month={viewDate}
|
month={viewDate}
|
||||||
onMonthChange={setViewDate}
|
onMonthChange={setViewDate}
|
||||||
className="rounded-md border-0 w-full p-0"
|
className="w-full rounded-md border-0 p-0"
|
||||||
classNames={{
|
classNames={{
|
||||||
root: "w-full p-0",
|
root: "w-full p-0",
|
||||||
months: "flex flex-col w-full",
|
months: "flex flex-col w-full",
|
||||||
@@ -173,17 +225,18 @@ export function InvoiceCalendarView({
|
|||||||
// Use calc(100%/7) via tailwind arbitrary or just flex bases.
|
// Use calc(100%/7) via tailwind arbitrary or just flex bases.
|
||||||
// Better: w-[14.28%] flex-none (approx 1/7)
|
// Better: w-[14.28%] flex-none (approx 1/7)
|
||||||
weekdays: "flex w-full border-b",
|
weekdays: "flex w-full border-b",
|
||||||
weekday: "w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
|
weekday:
|
||||||
|
"w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
|
||||||
|
|
||||||
week: "flex w-full mt-2",
|
week: "flex w-full mt-2",
|
||||||
cell: "w-[14.285%] flex-none h-32 border-b p-0 relative focus-within:relative focus-within:z-20 text-center text-sm",
|
cell: "w-[14.285%] flex-none h-20 sm:h-28 md:h-32 border-b p-0 relative focus-within:relative focus-within:z-20 text-center text-sm",
|
||||||
|
|
||||||
// Hide internal navigation & caption entirely
|
// Hide internal navigation & caption entirely
|
||||||
nav: "hidden",
|
nav: "hidden",
|
||||||
caption: "hidden",
|
caption: "hidden",
|
||||||
|
|
||||||
day: cn(
|
day: cn(
|
||||||
"w-full h-full p-2 font-normal aria-selected:opacity-100 flex flex-col items-start justify-start gap-1 hover:bg-accent/50 hover:text-accent-foreground align-top transition-colors rounded-xl"
|
"w-full h-full p-2 font-normal aria-selected:opacity-100 flex flex-col items-start justify-start gap-1 hover:bg-accent/50 hover:text-accent-foreground align-top transition-colors rounded-xl",
|
||||||
),
|
),
|
||||||
day_selected: "bg-primary/5 text-primary",
|
day_selected: "bg-primary/5 text-primary",
|
||||||
day_today: "bg-accent/20",
|
day_today: "bg-accent/20",
|
||||||
@@ -204,36 +257,61 @@ export function InvoiceCalendarView({
|
|||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex h-full w-full flex-col items-start justify-between p-2 transition-all rounded-xl border border-transparent hover:border-border/50 hover:bg-secondary/30 text-left overflow-hidden",
|
"hover:border-border/50 hover:bg-secondary/30 relative flex h-full w-full flex-col items-start justify-between overflow-hidden rounded-xl border border-transparent p-2 text-left transition-all",
|
||||||
// Selected State: Filled Box, No Outline
|
// Selected State: Filled Box, No Outline
|
||||||
modifiers.selected && "bg-primary text-primary-foreground hover:bg-primary/90 shadow-md transform scale-[0.98]",
|
modifiers.selected &&
|
||||||
modifiers.today && !modifiers.selected && "bg-accent/40 rounded-xl",
|
"bg-primary text-primary-foreground hover:bg-primary/90 scale-[0.98] transform shadow-md",
|
||||||
className
|
modifiers.today &&
|
||||||
|
!modifiers.selected &&
|
||||||
|
"bg-accent/40 rounded-xl",
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium z-10">{DayDate.getDate()}</span>
|
<span className="z-10 text-sm font-medium">
|
||||||
|
{DayDate.getDate()}
|
||||||
|
</span>
|
||||||
{dayItems.length > 0 && (
|
{dayItems.length > 0 && (
|
||||||
<div className="flex flex-col gap-1 w-full mt-1 overflow-hidden h-full justify-end pb-1">
|
<div className="mt-1 flex h-full w-full flex-col justify-end gap-1 overflow-hidden pb-1">
|
||||||
<div className="flex flex-col gap-1 w-full mt-1">
|
<div className="mt-1 flex w-full flex-col gap-1">
|
||||||
{dayItems.slice(0, 4).map((item, idx) => (
|
{dayItems.slice(0, 4).map((item, idx) => (
|
||||||
<div key={idx} className={cn("h-1 w-full rounded-full", modifiers.selected ? "bg-primary-foreground/50" : "bg-primary/50")} />
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"h-1 w-full rounded-full",
|
||||||
|
modifiers.selected
|
||||||
|
? "bg-primary-foreground/50"
|
||||||
|
: "bg-primary/50",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{dayItems.length > 4 && <div className={cn("h-1 w-1/3 rounded-full", modifiers.selected ? "bg-primary-foreground/30" : "bg-muted-foreground/30")} />}
|
{dayItems.length > 4 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-1 w-1/3 rounded-full",
|
||||||
|
modifiers.selected
|
||||||
|
? "bg-primary-foreground/30"
|
||||||
|
: "bg-muted-foreground/30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 p-4 h-full w-full">
|
<div className="flex w-full gap-3 overflow-x-auto p-4 pb-6">
|
||||||
{weekDays.map((day) => {
|
{weekDays.map((day) => {
|
||||||
const isSelected = date && isSameDay(day, date);
|
const isSelected = date && isSameDay(day, date);
|
||||||
const isToday = isSameDay(day, new Date());
|
const isToday = isSameDay(day, new Date());
|
||||||
const dayItems = getItemsForDate(day);
|
const dayItems = getItemsForDate(day);
|
||||||
const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0);
|
const totalHours = dayItems.reduce(
|
||||||
|
(acc, curr) => acc + curr.item.hours,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -241,34 +319,49 @@ export function InvoiceCalendarView({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelectDate(day)}
|
onClick={() => handleSelectDate(day)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col h-full min-h-[400px] border rounded-3xl p-4 text-left transition-all hover:bg-accent/30 w-full",
|
"hover:bg-accent/30 flex min-h-[260px] w-[120px] flex-shrink-0 flex-col rounded-3xl border p-3 text-left transition-all sm:w-auto sm:flex-1",
|
||||||
isSelected ? "ring-2 ring-primary ring-offset-2 bg-primary/5" : "bg-background/40",
|
isSelected
|
||||||
isToday && !isSelected ? "bg-accent/40" : ""
|
? "ring-primary bg-primary/5 ring-2 ring-offset-2"
|
||||||
|
: "bg-background/40",
|
||||||
|
isToday && !isSelected ? "bg-accent/40" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center mb-4 pb-4 border-b w-full">
|
<div className="mb-4 flex w-full flex-col items-center border-b pb-4">
|
||||||
<span className="text-xs font-bold text-muted-foreground uppercase">{format(day, "EEE")}</span>
|
<span className="text-muted-foreground text-xs font-bold uppercase">
|
||||||
<span className="text-2xl font-light">{format(day, "d")}</span>
|
{format(day, "EEE")}
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-light">
|
||||||
|
{format(day, "d")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 space-y-2 w-full overflow-hidden">
|
<div className="w-full flex-1 space-y-2 overflow-hidden">
|
||||||
{dayItems.length > 0 ? (
|
{dayItems.length > 0 ? (
|
||||||
dayItems.map(({ item }, i) => (
|
dayItems.map(({ item }, i) => (
|
||||||
<div key={i} className="bg-background rounded-xl p-2 text-xs shadow-sm border">
|
<div
|
||||||
<div className="font-medium line-clamp-2 text-wrap break-words">{item.description || "No description"}</div>
|
key={i}
|
||||||
<div className="text-muted-foreground whitespace-nowrap">{item.hours}h</div>
|
className="bg-background rounded-xl border p-2 text-xs shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="line-clamp-2 font-medium text-wrap break-words">
|
||||||
|
{item.description || "No description"}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground whitespace-nowrap">
|
||||||
|
{item.hours}h
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center text-muted-foreground/20">
|
<div className="text-muted-foreground/20 flex h-full items-center justify-center">
|
||||||
<Plus className="w-8 h-8" />
|
<Plus className="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dayItems.length > 0 && (
|
{dayItems.length > 0 && (
|
||||||
<div className="pt-2 mt-auto text-center w-full">
|
<div className="mt-auto w-full pt-2 text-center">
|
||||||
<span className="text-sm font-semibold">{totalHours}h Total</span>
|
<span className="text-sm font-semibold">
|
||||||
|
{totalHours}h Total
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -279,47 +372,60 @@ export function InvoiceCalendarView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sheet for Day Details */}
|
{/* Sheet for Day Details */}
|
||||||
<Sheet
|
<Sheet open={sheetOpen} onOpenChange={handleCloseSheet}>
|
||||||
open={sheetOpen}
|
<SheetContent
|
||||||
onOpenChange={handleCloseSheet}
|
side="right"
|
||||||
|
className="flex w-full max-w-full flex-col gap-0 p-0 sm:w-[400px] sm:max-w-[540px]"
|
||||||
>
|
>
|
||||||
<SheetContent side="right" className="w-[400px] sm:w-[540px] flex flex-col gap-0 p-0 sm:max-w-[540px]">
|
<SheetHeader className="border-b p-6">
|
||||||
<SheetHeader className="p-6 border-b">
|
<SheetTitle className="flex flex-wrap items-center gap-3 text-2xl">
|
||||||
<SheetTitle className="flex items-center gap-3 text-2xl flex-wrap">
|
<div className="bg-primary/10 flex-shrink-0 rounded-full p-2.5">
|
||||||
<div className="bg-primary/10 p-2.5 rounded-full flex-shrink-0">
|
<CalendarIcon className="text-primary h-6 w-6" />
|
||||||
<CalendarIcon className="w-6 h-6 text-primary" />
|
|
||||||
</div>
|
</div>
|
||||||
<span className="break-words text-left">{date ? format(date, "EEEE, MMMM do") : "Details"}</span>
|
<span className="text-left break-words">
|
||||||
|
{date ? format(date, "EEEE, MMMM do") : "Details"}
|
||||||
|
</span>
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{date && selectedDateItems.length === 0 ? (
|
{date && selectedDateItems.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4 bg-secondary/20 rounded-3xl border border-dashed border-border/60">
|
<div className="bg-secondary/20 border-border/60 flex flex-col items-center justify-center space-y-4 rounded-3xl border border-dashed py-16 text-center">
|
||||||
<div className="bg-background p-4 rounded-full shadow-sm">
|
<div className="bg-background rounded-full p-4 shadow-sm">
|
||||||
<Clock className="w-8 h-8 text-muted-foreground/50" />
|
<Clock className="text-muted-foreground/50 h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-semibold text-lg text-foreground">No hours logged</p>
|
<p className="text-foreground text-lg font-semibold">
|
||||||
<p className="text-sm text-muted-foreground/80 max-w-[200px]">There are no time entries recorded for this day yet.</p>
|
No hours logged
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground/80 max-w-[200px] text-sm">
|
||||||
|
There are no time entries recorded for this day yet.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
|
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Log Time
|
Log Time
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{selectedDateItems.map(({ item, index }) => (
|
{selectedDateItems.map(({ item, index }) => (
|
||||||
<div key={item.id} className="border-border bg-card overflow-hidden rounded-lg border group hover:border-primary/50 transition-colors">
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="border-border bg-card group hover:border-primary/50 overflow-hidden rounded-lg border transition-colors"
|
||||||
|
>
|
||||||
<div className="space-y-3 p-4">
|
<div className="space-y-3 p-4">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-muted-foreground text-xs">Description</Label>
|
<Label className="text-muted-foreground text-xs">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(e) => onUpdateItem(index, "description", e.target.value)}
|
onChange={(e) =>
|
||||||
|
onUpdateItem(index, "description", e.target.value)
|
||||||
|
}
|
||||||
placeholder="Describe the work performed..."
|
placeholder="Describe the work performed..."
|
||||||
className="pl-3 text-sm"
|
className="pl-3 text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -328,20 +434,24 @@ export function InvoiceCalendarView({
|
|||||||
{/* Hours and Rate in a row */}
|
{/* Hours and Rate in a row */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-muted-foreground text-xs">Hours</Label>
|
<Label className="text-muted-foreground text-xs">
|
||||||
|
Hours
|
||||||
|
</Label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={item.hours}
|
value={item.hours}
|
||||||
onChange={v => onUpdateItem(index, "hours", v)}
|
onChange={(v) => onUpdateItem(index, "hours", v)}
|
||||||
step={0.25}
|
step={0.25}
|
||||||
min={0}
|
min={0}
|
||||||
width="full"
|
width="full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-muted-foreground text-xs">Rate</Label>
|
<Label className="text-muted-foreground text-xs">
|
||||||
|
Rate
|
||||||
|
</Label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={item.rate}
|
value={item.rate}
|
||||||
onChange={v => onUpdateItem(index, "rate", v)}
|
onChange={(v) => onUpdateItem(index, "rate", v)}
|
||||||
prefix="$"
|
prefix="$"
|
||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
@@ -370,7 +480,9 @@ export function InvoiceCalendarView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<span className="text-muted-foreground text-xs">Total</span>
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Total
|
||||||
|
</span>
|
||||||
<span className="text-primary text-lg font-bold">
|
<span className="text-primary text-lg font-bold">
|
||||||
${(item.hours * item.rate).toFixed(2)}
|
${(item.hours * item.rate).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
@@ -378,9 +490,13 @@ export function InvoiceCalendarView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button variant="outline" onClick={handleAddNewItem} className="w-full border-dashed py-8 rounded-xl hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary transition-all gap-2 group">
|
<Button
|
||||||
<div className="bg-muted group-hover:bg-primary/10 p-1 rounded-md transition-colors">
|
variant="outline"
|
||||||
<Plus className="w-4 h-4" />
|
onClick={handleAddNewItem}
|
||||||
|
className="hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary group w-full gap-2 rounded-xl border-dashed py-8 transition-all"
|
||||||
|
>
|
||||||
|
<div className="bg-muted group-hover:bg-primary/10 rounded-md p-1 transition-colors">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span>Add Another Entry</span>
|
<span>Add Another Entry</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -388,8 +504,14 @@ export function InvoiceCalendarView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter className="p-6 border-t bg-muted/10 mt-auto">
|
<SheetFooter className="bg-muted/10 mt-auto border-t p-6">
|
||||||
<Button className="w-full sm:w-full rounded-xl h-12 text-base shadow-md" size="lg" onClick={() => handleCloseSheet(false)}>Done</Button>
|
<Button
|
||||||
|
className="h-12 w-full rounded-xl text-base shadow-md sm:w-full"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => handleCloseSheet(false)}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -15,13 +15,32 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { DatePicker } from "~/components/ui/date-picker";
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
import { InvoiceLineItems } from "./invoice-line-items";
|
import { InvoiceLineItems } from "./invoice-line-items";
|
||||||
import { InvoiceCalendarView } from "./invoice-calendar-view";
|
import { InvoiceCalendarView } from "./invoice-calendar-view";
|
||||||
|
import { EmailPreview } from "./email-preview";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Save, Calendar as CalendarIcon, Tag, User, List } from "lucide-react";
|
import {
|
||||||
|
Save,
|
||||||
|
Calendar as CalendarIcon,
|
||||||
|
Tag,
|
||||||
|
User,
|
||||||
|
List,
|
||||||
|
FileText,
|
||||||
|
ChevronDown,
|
||||||
|
Mail,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "~/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -41,7 +60,7 @@ interface InvoiceFormProps {
|
|||||||
|
|
||||||
function InvoiceFormSkeleton() {
|
function InvoiceFormSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<div className="space-y-6 pb-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Loading..."
|
title="Loading..."
|
||||||
description="Loading invoice form"
|
description="Loading invoice form"
|
||||||
@@ -57,20 +76,36 @@ function InvoiceFormSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
function getDefaultHourlyRate(value: unknown) {
|
||||||
const router = useRouter();
|
if (typeof value !== "object" || value === null) return null;
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
// State
|
const rate = (value as { defaultHourlyRate?: unknown }).defaultHourlyRate;
|
||||||
const [formData, setFormData] = useState<InvoiceFormData>({
|
return typeof rate === "number" ? rate : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainTextToHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultInvoiceFormData(): InvoiceFormData {
|
||||||
|
return {
|
||||||
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
||||||
|
invoicePrefix: "#",
|
||||||
businessId: "",
|
businessId: "",
|
||||||
clientId: "",
|
clientId: "",
|
||||||
issueDate: new Date(),
|
issueDate: new Date(),
|
||||||
dueDate: new Date(),
|
dueDate: new Date(),
|
||||||
status: "draft",
|
status: "draft",
|
||||||
notes: "",
|
notes: "",
|
||||||
|
emailMessage: "",
|
||||||
taxRate: 0,
|
taxRate: 0,
|
||||||
|
currency: "USD",
|
||||||
defaultHourlyRate: null,
|
defaultHourlyRate: null,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@@ -82,16 +117,30 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
amount: 0,
|
amount: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [formData, setFormData] = useState<InvoiceFormData>(
|
||||||
|
createDefaultInvoiceFormData,
|
||||||
|
);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState("details");
|
const [activeTab, setActiveTab] = useState("details");
|
||||||
|
const [previewTab, setPreviewTab] = useState("pdf");
|
||||||
|
|
||||||
// Queries (Same as before)
|
// Queries (Same as before)
|
||||||
const { data: clients, isLoading: loadingClients } =
|
const { data: clients, isLoading: loadingClients } =
|
||||||
api.clients.getAll.useQuery();
|
api.clients.getAll.useQuery();
|
||||||
|
const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({
|
||||||
|
type: "notes",
|
||||||
|
});
|
||||||
const { data: businesses, isLoading: loadingBusinesses } =
|
const { data: businesses, isLoading: loadingBusinesses } =
|
||||||
api.businesses.getAll.useQuery();
|
api.businesses.getAll.useQuery();
|
||||||
const { data: existingInvoice, isLoading: loadingInvoice } =
|
const { data: existingInvoice, isLoading: loadingInvoice } =
|
||||||
@@ -110,33 +159,34 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
|
|
||||||
// Init Effects (Same as before)
|
// Init Effects (Same as before)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset initialization state when the routed invoice changes.
|
||||||
setInitialized(false);
|
setInitialized(false);
|
||||||
}, [invoiceId]);
|
}, [invoiceId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
||||||
// ... (Mapping logic same as before)
|
// ... (Mapping logic same as before)
|
||||||
const mappedItems: InvoiceItem[] =
|
const mappedItems: InvoiceItem[] =
|
||||||
existingInvoice.items
|
existingInvoice.items?.map((item) => ({
|
||||||
?.map((item) => ({
|
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
date: new Date(item.date),
|
date: new Date(item.date),
|
||||||
description: item.description,
|
description: item.description,
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
}))
|
})) || [];
|
||||||
.sort(
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
|
||||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
|
||||||
) || [];
|
|
||||||
setFormData({
|
setFormData({
|
||||||
invoiceNumber: existingInvoice.invoiceNumber,
|
invoiceNumber: existingInvoice.invoiceNumber,
|
||||||
|
invoicePrefix: existingInvoice.invoicePrefix ?? "#",
|
||||||
businessId: existingInvoice.businessId ?? "",
|
businessId: existingInvoice.businessId ?? "",
|
||||||
clientId: existingInvoice.clientId,
|
clientId: existingInvoice.clientId,
|
||||||
issueDate: new Date(existingInvoice.issueDate),
|
issueDate: new Date(existingInvoice.issueDate),
|
||||||
dueDate: new Date(existingInvoice.dueDate),
|
dueDate: new Date(existingInvoice.dueDate),
|
||||||
status: existingInvoice.status as "draft" | "sent" | "paid",
|
status: existingInvoice.status as "draft" | "sent" | "paid",
|
||||||
notes: existingInvoice.notes ?? "",
|
notes: existingInvoice.notes ?? "",
|
||||||
|
emailMessage: existingInvoice.emailMessage ?? "",
|
||||||
taxRate: existingInvoice.taxRate,
|
taxRate: existingInvoice.taxRate,
|
||||||
|
currency: existingInvoice.currency ?? "USD",
|
||||||
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
|
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
|
||||||
items:
|
items:
|
||||||
mappedItems.length > 0
|
mappedItems.length > 0
|
||||||
@@ -175,6 +225,55 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
const total = subtotal + taxAmount;
|
const total = subtotal + taxAmount;
|
||||||
return { subtotal, taxAmount, total };
|
return { subtotal, taxAmount, total };
|
||||||
}, [formData.items, formData.taxRate]);
|
}, [formData.items, formData.taxRate]);
|
||||||
|
const emailPreviewMessage = React.useMemo(
|
||||||
|
() => plainTextToHtml(formData.emailMessage.trim()),
|
||||||
|
[formData.emailMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pdfPreviewInput = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
invoiceNumber: formData.invoiceNumber,
|
||||||
|
invoicePrefix: formData.invoicePrefix,
|
||||||
|
businessId: formData.businessId || "",
|
||||||
|
clientId: formData.clientId,
|
||||||
|
issueDate: formData.issueDate,
|
||||||
|
dueDate: formData.dueDate,
|
||||||
|
status: formData.status,
|
||||||
|
notes: formData.notes,
|
||||||
|
emailMessage: formData.emailMessage,
|
||||||
|
taxRate: formData.taxRate,
|
||||||
|
currency: formData.currency,
|
||||||
|
items: formData.items.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
description: item.description || "Service",
|
||||||
|
hours: item.hours,
|
||||||
|
rate: item.rate,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
[formData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: pdfPreview, isFetching: pdfPreviewLoading } =
|
||||||
|
api.invoices.previewPdf.useQuery(pdfPreviewInput, {
|
||||||
|
enabled:
|
||||||
|
activeTab === "preview" &&
|
||||||
|
previewTab === "pdf" &&
|
||||||
|
Boolean(formData.clientId) &&
|
||||||
|
formData.items.length > 0 &&
|
||||||
|
formData.items.every((item) => item.description.trim() !== ""),
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
const selectedClient = React.useMemo(
|
||||||
|
() => clients?.find((client) => client.id === formData.clientId),
|
||||||
|
[clients, formData.clientId],
|
||||||
|
);
|
||||||
|
const selectedBusiness = React.useMemo(
|
||||||
|
() =>
|
||||||
|
businesses?.find((business) => business.id === formData.businessId) ??
|
||||||
|
businesses?.find((business) => business.isDefault),
|
||||||
|
[businesses, formData.businessId],
|
||||||
|
);
|
||||||
|
|
||||||
// Handlers (addItem, updateItem etc. - same as before)
|
// Handlers (addItem, updateItem etc. - same as before)
|
||||||
const addItem = (date?: unknown) => {
|
const addItem = (date?: unknown) => {
|
||||||
@@ -220,32 +319,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
const moveItemUp = (idx: number) => {
|
|
||||||
if (idx === 0) return;
|
|
||||||
setFormData((prev) => {
|
|
||||||
const newItems = [...prev.items];
|
|
||||||
if (newItems[idx] && newItems[idx - 1]) {
|
|
||||||
const temp = newItems[idx - 1]!;
|
|
||||||
newItems[idx - 1] = newItems[idx];
|
|
||||||
newItems[idx] = temp;
|
|
||||||
}
|
|
||||||
return { ...prev, items: newItems };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const moveItemDown = (idx: number) => {
|
|
||||||
if (idx === formData.items.length - 1) return;
|
|
||||||
setFormData((prev) => {
|
|
||||||
const newItems = [...prev.items];
|
|
||||||
if (newItems[idx] && newItems[idx + 1]) {
|
|
||||||
const temp = newItems[idx + 1]!;
|
|
||||||
newItems[idx + 1] = newItems[idx];
|
|
||||||
newItems[idx] = temp;
|
|
||||||
}
|
|
||||||
return { ...prev, items: newItems };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const reorderItems = (newItems: InvoiceItem[]) =>
|
|
||||||
setFormData((prev) => ({ ...prev, items: newItems }));
|
|
||||||
|
|
||||||
const createInvoice = api.invoices.create.useMutation({
|
const createInvoice = api.invoices.create.useMutation({
|
||||||
onSuccess: (inv) => {
|
onSuccess: (inv) => {
|
||||||
@@ -322,18 +395,17 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
invoiceNumber: formData.invoiceNumber,
|
invoiceNumber: formData.invoiceNumber,
|
||||||
|
invoicePrefix: formData.invoicePrefix,
|
||||||
businessId: formData.businessId || "",
|
businessId: formData.businessId || "",
|
||||||
clientId: formData.clientId,
|
clientId: formData.clientId,
|
||||||
issueDate: formData.issueDate,
|
issueDate: formData.issueDate,
|
||||||
dueDate: formData.dueDate,
|
dueDate: formData.dueDate,
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
notes: formData.notes,
|
notes: formData.notes,
|
||||||
|
emailMessage: formData.emailMessage,
|
||||||
taxRate: formData.taxRate,
|
taxRate: formData.taxRate,
|
||||||
items: formData.items
|
currency: formData.currency,
|
||||||
.sort(
|
items: formData.items.map((i) => ({
|
||||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
|
||||||
)
|
|
||||||
.map((i) => ({
|
|
||||||
date: i.date,
|
date: i.date,
|
||||||
description: i.description,
|
description: i.description,
|
||||||
hours: i.hours,
|
hours: i.hours,
|
||||||
@@ -370,7 +442,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-enter space-y-6 pb-32">
|
<div className="page-enter space-y-6 pb-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
|
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
|
||||||
description="Manage your invoice"
|
description="Manage your invoice"
|
||||||
@@ -393,7 +465,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
|
|
||||||
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||||
{/* TAB SELECTOR: w-full, p-1, visible background */}
|
{/* TAB SELECTOR: w-full, p-1, visible background */}
|
||||||
<TabsList className="bg-muted grid h-auto w-full grid-cols-3 rounded-xl p-1">
|
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="details"
|
value="details"
|
||||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||||
@@ -412,6 +484,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
>
|
>
|
||||||
Timesheet
|
Timesheet
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="preview"
|
||||||
|
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* DETAILS TAB */}
|
{/* DETAILS TAB */}
|
||||||
@@ -419,7 +497,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
value="details"
|
value="details"
|
||||||
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2"
|
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2"
|
||||||
>
|
>
|
||||||
<Card className="h-fit">
|
<Card className="h-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex gap-2 text-base">
|
<CardTitle className="flex gap-2 text-base">
|
||||||
<User className="h-4 w-4" /> Client Details
|
<User className="h-4 w-4" /> Client Details
|
||||||
@@ -432,26 +510,25 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
value={formData.clientId}
|
value={formData.clientId}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
updateField("clientId", v);
|
updateField("clientId", v);
|
||||||
// Auto-fill Hourly Rate
|
|
||||||
const selectedClient = clients?.find((c) => c.id === v);
|
const selectedClient = clients?.find((c) => c.id === v);
|
||||||
const currentBusiness = businesses?.find(
|
const currentBusiness = businesses?.find(
|
||||||
(b) => b.id === formData.businessId,
|
(b) => b.id === formData.businessId,
|
||||||
);
|
);
|
||||||
// Explicitly prioritize client rate, then business rate, then 0
|
const clientRate = getDefaultHourlyRate(selectedClient);
|
||||||
const clientRate =
|
|
||||||
selectedClient && "defaultHourlyRate" in selectedClient
|
|
||||||
? selectedClient.defaultHourlyRate
|
|
||||||
: null;
|
|
||||||
const businessRate =
|
const businessRate =
|
||||||
currentBusiness &&
|
getDefaultHourlyRate(currentBusiness);
|
||||||
"defaultHourlyRate" in currentBusiness
|
updateField(
|
||||||
? currentBusiness.defaultHourlyRate
|
"defaultHourlyRate",
|
||||||
: null;
|
clientRate ?? businessRate ?? 0,
|
||||||
const rateToSet: number = (clientRate ??
|
);
|
||||||
businessRate ??
|
// Auto-fill currency from client
|
||||||
0) as number;
|
if (
|
||||||
|
selectedClient &&
|
||||||
updateField("defaultHourlyRate", rateToSet);
|
"currency" in selectedClient &&
|
||||||
|
selectedClient.currency
|
||||||
|
) {
|
||||||
|
updateField("currency", selectedClient.currency);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
@@ -487,14 +564,14 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="h-fit">
|
<Card className="h-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex gap-2 text-base">
|
<CardTitle className="flex gap-2 text-base">
|
||||||
<Tag className="h-4 w-4" /> Invoice Config
|
<Tag className="h-4 w-4" /> Invoice Settings
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Issue Date</Label>
|
<Label>Issue Date</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -516,6 +593,30 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[96px_1fr] sm:gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Prefix</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.invoicePrefix}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField("invoicePrefix", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="#"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Invoice Number</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.invoiceNumber}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField("invoiceNumber", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="INV-20260428-000001"
|
||||||
|
className="w-full font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Tax Rate</Label>
|
<Label>Tax Rate</Label>
|
||||||
@@ -537,6 +638,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Status</Label>
|
<Label>Status</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -557,6 +659,84 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Currency</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.currency}
|
||||||
|
onValueChange={(v) => updateField("currency", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SUPPORTED_CURRENCIES.map((c) => (
|
||||||
|
<SelectItem key={c.code} value={c.code}>
|
||||||
|
{c.code}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between gap-2 text-base">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4" /> Email Message
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={formData.emailMessage}
|
||||||
|
onChange={(e) => updateField("emailMessage", e.target.value)}
|
||||||
|
placeholder="Add a note that appears only in the email body..."
|
||||||
|
className="min-h-[140px]"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between gap-2 text-base">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4" /> Invoice Notes
|
||||||
|
</span>
|
||||||
|
{noteTemplates && noteTemplates.length > 0 && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 text-xs"
|
||||||
|
>
|
||||||
|
Use template <ChevronDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
{noteTemplates.map((t) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => updateField("notes", t.content)}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) => updateField("notes", e.target.value)}
|
||||||
|
placeholder="Add notes, payment terms, or other information for the invoice/PDF..."
|
||||||
|
className="min-h-[140px]"
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -607,9 +787,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
onAddItem={addItem}
|
onAddItem={addItem}
|
||||||
onRemoveItem={removeItem}
|
onRemoveItem={removeItem}
|
||||||
onUpdateItem={updateItem}
|
onUpdateItem={updateItem}
|
||||||
onMoveUp={moveItemUp}
|
|
||||||
onMoveDown={moveItemDown}
|
|
||||||
onReorderItems={reorderItems}
|
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -637,6 +814,122 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="preview"
|
||||||
|
className="mt-6 focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
value={previewTab}
|
||||||
|
onValueChange={setPreviewTab}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1">
|
||||||
|
<TabsTrigger
|
||||||
|
value="pdf"
|
||||||
|
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
PDF
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="email"
|
||||||
|
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="pdf" className="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex gap-2">
|
||||||
|
<FileText className="h-5 w-5" /> PDF Preview
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="bg-muted/20 h-[760px] overflow-hidden border-t">
|
||||||
|
{!formData.clientId ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||||
|
Select a client to generate the PDF preview.
|
||||||
|
</div>
|
||||||
|
) : formData.items.some(
|
||||||
|
(item) => item.description.trim() === "",
|
||||||
|
) ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||||
|
Add descriptions for all line items to generate the
|
||||||
|
PDF preview.
|
||||||
|
</div>
|
||||||
|
) : pdfPreviewLoading && !pdfPreview ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||||
|
Generating server PDF preview...
|
||||||
|
</div>
|
||||||
|
) : pdfPreview ? (
|
||||||
|
<iframe
|
||||||
|
title="Server-generated PDF preview"
|
||||||
|
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
|
||||||
|
className="h-full w-full border-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||||
|
PDF preview will appear here.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="email" className="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex gap-2">
|
||||||
|
<Mail className="h-5 w-5" /> Email Preview
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<EmailPreview
|
||||||
|
subject={`Invoice ${formData.invoiceNumber} from ${
|
||||||
|
selectedBusiness?.name ?? "Your Business"
|
||||||
|
}`}
|
||||||
|
fromEmail={selectedBusiness?.email ?? ""}
|
||||||
|
toEmail={selectedClient?.email ?? ""}
|
||||||
|
content=""
|
||||||
|
customMessage={emailPreviewMessage}
|
||||||
|
invoice={{
|
||||||
|
invoiceNumber: formData.invoiceNumber,
|
||||||
|
issueDate: formData.issueDate,
|
||||||
|
dueDate: formData.dueDate,
|
||||||
|
taxRate: formData.taxRate,
|
||||||
|
status: formData.status,
|
||||||
|
totalAmount: totals.total,
|
||||||
|
currency: formData.currency,
|
||||||
|
client: selectedClient
|
||||||
|
? {
|
||||||
|
name: selectedClient.name,
|
||||||
|
email: selectedClient.email,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
business: selectedBusiness
|
||||||
|
? {
|
||||||
|
name: selectedBusiness.name,
|
||||||
|
email: selectedBusiness.email,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
items: formData.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
date: item.date,
|
||||||
|
description: item.description,
|
||||||
|
hours: item.hours,
|
||||||
|
rate: item.rate,
|
||||||
|
amount: item.hours * item.rate,
|
||||||
|
})),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -33,9 +28,6 @@ interface InvoiceLineItemsProps {
|
|||||||
field: string,
|
field: string,
|
||||||
value: string | number | Date,
|
value: string | number | Date,
|
||||||
) => void;
|
) => void;
|
||||||
onMoveUp: (index: number) => void;
|
|
||||||
onMoveDown: (index: number) => void;
|
|
||||||
onReorderItems: (items: InvoiceItem[]) => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,116 +41,56 @@ interface LineItemRowProps {
|
|||||||
field: string,
|
field: string,
|
||||||
value: string | number | Date,
|
value: string | number | Date,
|
||||||
) => void;
|
) => void;
|
||||||
onMoveUp: (index: number) => void;
|
|
||||||
onMoveDown: (index: number) => void;
|
|
||||||
isFirst: boolean;
|
|
||||||
isLast: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||||
(
|
({ item, index, canRemove, onRemove, onUpdate }, ref) => {
|
||||||
{
|
|
||||||
item,
|
|
||||||
index,
|
|
||||||
canRemove,
|
|
||||||
onRemove,
|
|
||||||
onUpdate,
|
|
||||||
onMoveUp,
|
|
||||||
onMoveDown,
|
|
||||||
isFirst,
|
|
||||||
isLast,
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card border hidden rounded-xl p-4 md:block transition-all shadow-sm group hover:border-primary/20",
|
"group hover:bg-muted/40 hidden min-h-16 grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] items-center gap-2 border-b px-3 py-2 transition-colors md:grid",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<DatePicker
|
||||||
{/* Arrow Controls */}
|
date={item.date}
|
||||||
<div className="flex flex-col gap-0.5">
|
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onMoveUp(index)}
|
className="w-full"
|
||||||
className="h-6 w-6 p-0"
|
inputClassName="h-9"
|
||||||
disabled={isFirst}
|
/>
|
||||||
aria-label="Move up"
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onMoveDown(index)}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
disabled={isLast}
|
|
||||||
aria-label="Move down"
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="flex-1 space-y-3">
|
|
||||||
{/* Description */}
|
|
||||||
<div>
|
|
||||||
<Input
|
<Input
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(e) => onUpdate(index, "description", e.target.value)}
|
onChange={(e) => onUpdate(index, "description", e.target.value)}
|
||||||
placeholder="Describe the work performed..."
|
placeholder="Describe the work performed..."
|
||||||
className="w-full text-sm font-medium"
|
className="h-9 w-full text-sm font-medium"
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls Row */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
{/* Date */}
|
|
||||||
<DatePicker
|
|
||||||
date={item.date}
|
|
||||||
onDateChange={(date) =>
|
|
||||||
onUpdate(index, "date", date ?? new Date())
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
className="w-full sm:w-[180px]"
|
|
||||||
inputClassName="h-9"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hours */}
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={item.hours}
|
value={item.hours}
|
||||||
onChange={(value) => onUpdate(index, "hours", value)}
|
onChange={(value) => onUpdate(index, "hours", value)}
|
||||||
min={0}
|
min={0}
|
||||||
step={0.25}
|
step={0.25}
|
||||||
width="auto"
|
width="full"
|
||||||
className="h-9 flex-1 min-w-[100px] font-mono"
|
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12"
|
||||||
suffix="h"
|
suffix="h"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Rate */}
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={item.rate}
|
value={item.rate}
|
||||||
onChange={(value) => onUpdate(index, "rate", value)}
|
onChange={(value) => onUpdate(index, "rate", value)}
|
||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
prefix="$"
|
prefix="$"
|
||||||
width="auto"
|
width="full"
|
||||||
className="h-9 flex-1 min-w-[100px] font-mono"
|
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-14"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Amount */}
|
<div className="text-primary text-right font-mono font-semibold">
|
||||||
<div className="ml-auto">
|
|
||||||
<span className="text-primary font-semibold">
|
|
||||||
${(item.hours * item.rate).toFixed(2)}
|
${(item.hours * item.rate).toFixed(2)}
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -171,9 +103,6 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -185,10 +114,6 @@ function MobileLineItem({
|
|||||||
canRemove,
|
canRemove,
|
||||||
onRemove,
|
onRemove,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onMoveUp,
|
|
||||||
onMoveDown,
|
|
||||||
isFirst,
|
|
||||||
isLast,
|
|
||||||
}: LineItemRowProps) {
|
}: LineItemRowProps) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -253,28 +178,6 @@ function MobileLineItem({
|
|||||||
{/* Bottom section with controls, item name, and total */}
|
{/* 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="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onMoveUp(index)}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
disabled={isFirst}
|
|
||||||
aria-label="Move up"
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onMoveDown(index)}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
disabled={isLast}
|
|
||||||
aria-label="Move down"
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -310,8 +213,6 @@ export function InvoiceLineItems({
|
|||||||
onAddItem,
|
onAddItem,
|
||||||
onRemoveItem,
|
onRemoveItem,
|
||||||
onUpdateItem,
|
onUpdateItem,
|
||||||
onMoveUp,
|
|
||||||
onMoveDown,
|
|
||||||
className,
|
className,
|
||||||
}: InvoiceLineItemsProps) {
|
}: InvoiceLineItemsProps) {
|
||||||
const canRemoveItems = items.length > 1;
|
const canRemoveItems = items.length > 1;
|
||||||
@@ -319,7 +220,15 @@ export function InvoiceLineItems({
|
|||||||
return (
|
return (
|
||||||
<div className={cn("space-y-2", className)}>
|
<div className={cn("space-y-2", className)}>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 md:space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
||||||
|
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] gap-2 border-b px-3 py-2 text-xs font-medium md:grid">
|
||||||
|
<span>Date</span>
|
||||||
|
<span>Description</span>
|
||||||
|
<span className="text-right">Hours</span>
|
||||||
|
<span className="text-right">Rate</span>
|
||||||
|
<span className="text-right">Amount</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<React.Fragment key={item.id}>
|
<React.Fragment key={item.id}>
|
||||||
{/* Desktop/Tablet Card */}
|
{/* Desktop/Tablet Card */}
|
||||||
@@ -337,10 +246,6 @@ export function InvoiceLineItems({
|
|||||||
canRemove={canRemoveItems}
|
canRemove={canRemoveItems}
|
||||||
onRemove={onRemoveItem}
|
onRemove={onRemoveItem}
|
||||||
onUpdate={onUpdateItem}
|
onUpdate={onUpdateItem}
|
||||||
onMoveUp={onMoveUp}
|
|
||||||
onMoveDown={onMoveDown}
|
|
||||||
isFirst={index === 0}
|
|
||||||
isLast={index === items.length - 1}
|
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -351,10 +256,6 @@ export function InvoiceLineItems({
|
|||||||
canRemove={canRemoveItems}
|
canRemove={canRemoveItems}
|
||||||
onRemove={onRemoveItem}
|
onRemove={onRemoveItem}
|
||||||
onUpdate={onUpdateItem}
|
onUpdate={onUpdateItem}
|
||||||
onMoveUp={onMoveUp}
|
|
||||||
onMoveDown={onMoveDown}
|
|
||||||
isFirst={index === 0}
|
|
||||||
isLast={index === items.length - 1}
|
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
@@ -362,19 +263,15 @@ export function InvoiceLineItems({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Add Item Button */}
|
{/* Add Item Button */}
|
||||||
<div className="px-3 pt-3">
|
|
||||||
<div className="border-t pt-6">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onAddItem}
|
onClick={onAddItem}
|
||||||
className="w-full border-dashed border-border py-8 text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 transition-all"
|
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 mt-3 w-full border-dashed py-6 transition-all"
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Line Item
|
Add Line Item
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,20 +13,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import {
|
import { STATUS_OPTIONS } from "./types";
|
||||||
STATUS_OPTIONS,
|
import type { InvoiceFormData, ClientType, BusinessType } from "./types";
|
||||||
} from "./types";
|
|
||||||
import type {
|
|
||||||
InvoiceFormData,
|
|
||||||
ClientType,
|
|
||||||
BusinessType,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
interface InvoiceMetaSidebarProps {
|
interface InvoiceMetaSidebarProps {
|
||||||
formData: InvoiceFormData;
|
formData: InvoiceFormData;
|
||||||
updateField: <K extends keyof InvoiceFormData>(
|
updateField: <K extends keyof InvoiceFormData>(
|
||||||
field: K,
|
field: K,
|
||||||
value: InvoiceFormData[K]
|
value: InvoiceFormData[K],
|
||||||
) => void;
|
) => void;
|
||||||
clients: ClientType[] | undefined;
|
clients: ClientType[] | undefined;
|
||||||
businesses: BusinessType[] | undefined;
|
businesses: BusinessType[] | undefined;
|
||||||
@@ -41,15 +35,17 @@ export function InvoiceMetaSidebar({
|
|||||||
className,
|
className,
|
||||||
}: InvoiceMetaSidebarProps) {
|
}: InvoiceMetaSidebarProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-6 p-4 h-full", className)}>
|
<div className={cn("flex h-full flex-col gap-6 p-4", className)}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
|
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||||
Invoice Details
|
Invoice Details
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="status" className="text-xs">Status</Label>
|
<Label htmlFor="status" className="text-xs">
|
||||||
|
Status
|
||||||
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.status}
|
value={formData.status}
|
||||||
onValueChange={(value: "draft" | "sent" | "paid") =>
|
onValueChange={(value: "draft" | "sent" | "paid") =>
|
||||||
@@ -71,7 +67,9 @@ export function InvoiceMetaSidebar({
|
|||||||
|
|
||||||
{/* Invoice Number */}
|
{/* Invoice Number */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="invoiceNumber" className="text-xs">Invoice Number</Label>
|
<Label htmlFor="invoiceNumber" className="text-xs">
|
||||||
|
Invoice Number
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="invoiceNumber"
|
id="invoiceNumber"
|
||||||
value={formData.invoiceNumber}
|
value={formData.invoiceNumber}
|
||||||
@@ -83,18 +81,23 @@ export function InvoiceMetaSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
|
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||||
Involved Parties
|
Involved Parties
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* From (Business) */}
|
{/* From (Business) */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="business" className="text-xs">From (Business)</Label>
|
<Label htmlFor="business" className="text-xs">
|
||||||
|
From (Business)
|
||||||
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.businessId}
|
value={formData.businessId}
|
||||||
onValueChange={(value) => updateField("businessId", value)}
|
onValueChange={(value) => updateField("businessId", value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger aria-label="From Business" className="bg-background/50 text-sm">
|
<SelectTrigger
|
||||||
|
aria-label="From Business"
|
||||||
|
className="bg-background/50 text-sm"
|
||||||
|
>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
<SelectValue placeholder="Select business" />
|
<SelectValue placeholder="Select business" />
|
||||||
</span>
|
</span>
|
||||||
@@ -102,7 +105,8 @@ export function InvoiceMetaSidebar({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{businesses?.map((business) => (
|
{businesses?.map((business) => (
|
||||||
<SelectItem key={business.id} value={business.id}>
|
<SelectItem key={business.id} value={business.id}>
|
||||||
{business.name}{business.nickname ? ` (${business.nickname})` : ""}
|
{business.name}
|
||||||
|
{business.nickname ? ` (${business.nickname})` : ""}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -111,12 +115,17 @@ export function InvoiceMetaSidebar({
|
|||||||
|
|
||||||
{/* Bill To (Client) */}
|
{/* Bill To (Client) */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="client" className="text-xs">Bill To (Client)</Label>
|
<Label htmlFor="client" className="text-xs">
|
||||||
|
Bill To (Client)
|
||||||
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.clientId}
|
value={formData.clientId}
|
||||||
onValueChange={(value) => updateField("clientId", value)}
|
onValueChange={(value) => updateField("clientId", value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger aria-label="Bill To Client" className="bg-background/50 text-sm">
|
<SelectTrigger
|
||||||
|
aria-label="Bill To Client"
|
||||||
|
className="bg-background/50 text-sm"
|
||||||
|
>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
<SelectValue placeholder="Select client" />
|
<SelectValue placeholder="Select client" />
|
||||||
</span>
|
</span>
|
||||||
@@ -133,7 +142,7 @@ export function InvoiceMetaSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
|
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||||
Dates
|
Dates
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -141,23 +150,27 @@ export function InvoiceMetaSidebar({
|
|||||||
<Label className="text-xs">Issued</Label>
|
<Label className="text-xs">Issued</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
date={formData.issueDate}
|
date={formData.issueDate}
|
||||||
onDateChange={(date) => updateField("issueDate", date ?? new Date())}
|
onDateChange={(date) =>
|
||||||
className="w-full bg-background/50"
|
updateField("issueDate", date ?? new Date())
|
||||||
|
}
|
||||||
|
className="bg-background/50 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Due</Label>
|
<Label className="text-xs">Due</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
date={formData.dueDate}
|
date={formData.dueDate}
|
||||||
onDateChange={(date) => updateField("dueDate", date ?? new Date())}
|
onDateChange={(date) =>
|
||||||
className="w-full bg-background/50"
|
updateField("dueDate", date ?? new Date())
|
||||||
|
}
|
||||||
|
className="bg-background/50 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
|
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||||
Config
|
Config
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -182,19 +195,22 @@ export function InvoiceMetaSidebar({
|
|||||||
prefix="$"
|
prefix="$"
|
||||||
placeholder={!formData.clientId ? "Select client" : "Rate"}
|
placeholder={!formData.clientId ? "Select client" : "Rate"}
|
||||||
disabled={!formData.clientId}
|
disabled={!formData.clientId}
|
||||||
className={cn("bg-background/50", !formData.clientId && "opacity-50")}
|
className={cn(
|
||||||
|
"bg-background/50",
|
||||||
|
!formData.clientId && "opacity-50",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5 flex-1">
|
<div className="flex-1 space-y-1.5">
|
||||||
<Label className="text-xs">Notes</Label>
|
<Label className="text-xs">Notes</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={formData.notes}
|
value={formData.notes}
|
||||||
onChange={(e) => updateField("notes", e.target.value)}
|
onChange={(e) => updateField("notes", e.target.value)}
|
||||||
placeholder="Notes for client..."
|
placeholder="Notes for client..."
|
||||||
className="bg-background/50 resize-none h-24"
|
className="bg-background/50 h-24 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ interface InvoiceWorkspaceProps {
|
|||||||
setViewMode: (mode: "list" | "calendar") => void;
|
setViewMode: (mode: "list" | "calendar") => void;
|
||||||
addItem: (date?: Date) => void;
|
addItem: (date?: Date) => void;
|
||||||
removeItem: (index: number) => void;
|
removeItem: (index: number) => void;
|
||||||
updateItem: (index: number, field: string, value: string | number | Date) => void;
|
updateItem: (
|
||||||
moveItemUp: (index: number) => void;
|
index: number,
|
||||||
moveItemDown: (index: number) => void;
|
field: string,
|
||||||
reorderItems: (items: InvoiceFormData['items']) => void;
|
value: string | number | Date,
|
||||||
|
) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,61 +29,55 @@ export function InvoiceWorkspace({
|
|||||||
addItem,
|
addItem,
|
||||||
removeItem,
|
removeItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
moveItemUp,
|
|
||||||
moveItemDown,
|
|
||||||
reorderItems,
|
|
||||||
className,
|
className,
|
||||||
}: InvoiceWorkspaceProps) {
|
}: InvoiceWorkspaceProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full", className)}>
|
<div className={cn("flex h-full flex-col", className)}>
|
||||||
{/* Workspace Header / View Toggle */}
|
{/* Workspace Header / View Toggle */}
|
||||||
<div className="flex items-center justify-between p-4 border-b bg-background/50 backdrop-blur-sm sticky top-0 z-10">
|
<div className="bg-background/50 sticky top-0 z-10 flex items-center justify-between border-b p-4 backdrop-blur-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-lg font-semibold tracking-tight">
|
<h2 className="text-lg font-semibold tracking-tight">
|
||||||
{viewMode === 'list' ? 'Line Items' : 'Timesheet'}
|
{viewMode === "list" ? "Line Items" : "Timesheet"}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="text-sm text-muted-foreground ml-2">
|
<div className="text-muted-foreground ml-2 text-sm">
|
||||||
{formData.items.length} {formData.items.length === 1 ? 'entry' : 'entries'}
|
{formData.items.length}{" "}
|
||||||
|
{formData.items.length === 1 ? "entry" : "entries"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center bg-secondary/50 p-1 rounded-lg">
|
<div className="bg-secondary/50 flex items-center rounded-lg p-1">
|
||||||
<Button
|
<Button
|
||||||
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode("list")}
|
||||||
className="h-8 gap-2 text-xs"
|
className="h-8 gap-2 text-xs"
|
||||||
>
|
>
|
||||||
<List className="w-3.5 h-3.5" />
|
<List className="h-3.5 w-3.5" />
|
||||||
List
|
List
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={viewMode === 'calendar' ? 'secondary' : 'ghost'}
|
variant={viewMode === "calendar" ? "secondary" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode('calendar')}
|
onClick={() => setViewMode("calendar")}
|
||||||
className="h-8 gap-2 text-xs"
|
className="h-8 gap-2 text-xs"
|
||||||
>
|
>
|
||||||
<CalendarIcon className="w-3.5 h-3.5" />
|
<CalendarIcon className="h-3.5 w-3.5" />
|
||||||
Calendar
|
Calendar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Workspace Content */}
|
{/* Workspace Content */}
|
||||||
<div className="flex-1 overflow-hidden relative">
|
<div className="relative flex-1 overflow-hidden">
|
||||||
<div className="absolute inset-0 overflow-y-auto p-6 md:p-8">
|
<div className="absolute inset-0 overflow-y-auto p-6 md:p-8">
|
||||||
{viewMode === 'list' ? (
|
{viewMode === "list" ? (
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
<div className="bg-background/40 backdrop-blur-md rounded-xl border border-white/10 p-1">
|
<div className="bg-background/40 rounded-xl border border-white/10 p-1 backdrop-blur-md">
|
||||||
<InvoiceLineItems
|
<InvoiceLineItems
|
||||||
items={formData.items}
|
items={formData.items}
|
||||||
onAddItem={() => addItem()}
|
onAddItem={() => addItem()}
|
||||||
onRemoveItem={removeItem}
|
onRemoveItem={removeItem}
|
||||||
onUpdateItem={updateItem}
|
onUpdateItem={updateItem}
|
||||||
onMoveUp={moveItemUp}
|
|
||||||
onMoveDown={moveItemDown}
|
|
||||||
onReorderItems={reorderItems}
|
|
||||||
className="p-4"
|
className="p-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,13 +14,16 @@ export interface InvoiceItem {
|
|||||||
|
|
||||||
export interface InvoiceFormData {
|
export interface InvoiceFormData {
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
|
invoicePrefix: string;
|
||||||
businessId: string;
|
businessId: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
issueDate: Date;
|
issueDate: Date;
|
||||||
dueDate: Date;
|
dueDate: Date;
|
||||||
status: "draft" | "sent" | "paid";
|
status: "draft" | "sent" | "paid";
|
||||||
notes: string;
|
notes: string;
|
||||||
|
emailMessage: string;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
|
currency: string;
|
||||||
defaultHourlyRate: number | null;
|
defaultHourlyRate: number | null;
|
||||||
items: InvoiceItem[];
|
items: InvoiceItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,451 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "~/components/ui/dialog";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { EmailComposer } from "./email-composer";
|
|
||||||
import { EmailPreview } from "./email-preview";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import {
|
|
||||||
Send,
|
|
||||||
Loader2,
|
|
||||||
Eye,
|
|
||||||
Edit3,
|
|
||||||
CheckCircle,
|
|
||||||
AlertTriangle,
|
|
||||||
Mail,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
|
||||||
|
|
||||||
interface SendEmailDialogProps {
|
|
||||||
invoiceId: string;
|
|
||||||
trigger: React.ReactNode;
|
|
||||||
invoice?: {
|
|
||||||
id: string;
|
|
||||||
invoiceNumber: string;
|
|
||||||
issueDate: Date;
|
|
||||||
dueDate: Date;
|
|
||||||
status: string;
|
|
||||||
taxRate: number;
|
|
||||||
client?: {
|
|
||||||
name: string;
|
|
||||||
email: string | null;
|
|
||||||
};
|
|
||||||
business?: {
|
|
||||||
name: string;
|
|
||||||
email: string | null;
|
|
||||||
};
|
|
||||||
items?: Array<{
|
|
||||||
id: string;
|
|
||||||
hours: number;
|
|
||||||
rate: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
onEmailSent?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SendEmailDialog({
|
|
||||||
invoiceId,
|
|
||||||
trigger,
|
|
||||||
invoice,
|
|
||||||
onEmailSent,
|
|
||||||
}: SendEmailDialogProps) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [activeTab, setActiveTab] = useState("compose");
|
|
||||||
const [isSending, setIsSending] = useState(false);
|
|
||||||
const [isConfirming, setIsConfirming] = useState(false);
|
|
||||||
|
|
||||||
// Email content state
|
|
||||||
const [subject, setSubject] = useState(() =>
|
|
||||||
invoice
|
|
||||||
? `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`
|
|
||||||
: "Invoice from Your Business",
|
|
||||||
);
|
|
||||||
const [ccEmail, setCcEmail] = useState("");
|
|
||||||
const [bccEmail, setBccEmail] = useState("");
|
|
||||||
const [customMessage, setCustomMessage] = useState("");
|
|
||||||
|
|
||||||
const [emailContent, setEmailContent] = useState(() => {
|
|
||||||
const getTimeOfDayGreeting = () => {
|
|
||||||
const hour = new Date().getHours();
|
|
||||||
if (hour < 12) return "Good morning";
|
|
||||||
if (hour < 17) return "Good afternoon";
|
|
||||||
return "Good evening";
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
}).format(new Date(date));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!invoice) return "";
|
|
||||||
|
|
||||||
const businessName = invoice.business?.name ?? "Your Business";
|
|
||||||
|
|
||||||
const issueDate = formatDate(invoice.issueDate);
|
|
||||||
|
|
||||||
// Calculate total from items
|
|
||||||
const subtotal =
|
|
||||||
invoice.items?.reduce((sum, item) => sum + item.hours * item.rate, 0) ??
|
|
||||||
0;
|
|
||||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
|
||||||
const total = subtotal + taxAmount;
|
|
||||||
|
|
||||||
return `<p>${getTimeOfDayGreeting()},</p>
|
|
||||||
|
|
||||||
<p>I hope this email finds you well. Please find attached invoice <strong>${invoice.invoiceNumber}</strong> dated ${issueDate}.</p>
|
|
||||||
|
|
||||||
<p>The invoice details are as follows:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Invoice Number:</strong> ${invoice.invoiceNumber}</li>
|
|
||||||
<li><strong>Issue Date:</strong> ${issueDate}</li>
|
|
||||||
<li><strong>Amount Due:</strong> ${new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(total)}</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>Please let me know if you have any questions or need any clarification regarding this invoice. I appreciate your prompt attention to this matter.</p>
|
|
||||||
|
|
||||||
<p>Thank you for your business!</p>
|
|
||||||
|
|
||||||
<p>Best regards,<br><strong>${businessName}</strong></p>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get utils for cache invalidation
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
// Email sending mutation
|
|
||||||
const sendEmailMutation = api.email.sendInvoice.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast.success("Email sent successfully!", {
|
|
||||||
description: data.message,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset state and close dialog
|
|
||||||
setIsOpen(false);
|
|
||||||
setActiveTab("compose");
|
|
||||||
setIsSending(false);
|
|
||||||
setIsConfirming(false);
|
|
||||||
|
|
||||||
// Refresh invoice data
|
|
||||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
|
||||||
|
|
||||||
// Callback for parent component
|
|
||||||
onEmailSent?.();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Email send error:", error);
|
|
||||||
|
|
||||||
let errorMessage = "Failed to send invoice email";
|
|
||||||
let errorDescription = error.message;
|
|
||||||
|
|
||||||
if (error.message.includes("Invalid recipient")) {
|
|
||||||
errorMessage = "Invalid Email Address";
|
|
||||||
errorDescription =
|
|
||||||
"Please check the client's email address and try again.";
|
|
||||||
} else if (error.message.includes("domain not verified")) {
|
|
||||||
errorMessage = "Email Configuration Issue";
|
|
||||||
errorDescription = "Please contact support to configure email sending.";
|
|
||||||
} else if (error.message.includes("rate limit")) {
|
|
||||||
errorMessage = "Too Many Emails";
|
|
||||||
errorDescription = "Please wait a moment before sending another email.";
|
|
||||||
} else if (error.message.includes("no email address")) {
|
|
||||||
errorMessage = "No Email Address";
|
|
||||||
errorDescription = "This client doesn't have an email address on file.";
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error(errorMessage, {
|
|
||||||
description: errorDescription,
|
|
||||||
duration: 6000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsSending(false);
|
|
||||||
setIsConfirming(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSendEmail = async () => {
|
|
||||||
if (!invoice?.client?.email || invoice.client.email.trim() === "") {
|
|
||||||
toast.error("No email address", {
|
|
||||||
description: "This client doesn't have an email address on file.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subject.trim()) {
|
|
||||||
toast.error("Subject required", {
|
|
||||||
description: "Please enter an email subject before sending.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!emailContent.trim()) {
|
|
||||||
toast.error("Message required", {
|
|
||||||
description: "Please enter an email message before sending.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSending(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the enhanced API with custom subject and content
|
|
||||||
await sendEmailMutation.mutateAsync({
|
|
||||||
invoiceId,
|
|
||||||
customSubject: subject,
|
|
||||||
customContent: emailContent,
|
|
||||||
customMessage: customMessage.trim() || undefined,
|
|
||||||
useHtml: true,
|
|
||||||
ccEmails: ccEmail.trim() || undefined,
|
|
||||||
bccEmails: bccEmail.trim() || undefined,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Error handling is done in the mutation's onError
|
|
||||||
console.error("Send email error:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmSend = () => {
|
|
||||||
setIsConfirming(true);
|
|
||||||
setActiveTab("confirm");
|
|
||||||
};
|
|
||||||
|
|
||||||
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com";
|
|
||||||
const toEmail = invoice?.client?.email ?? "";
|
|
||||||
|
|
||||||
const canSend =
|
|
||||||
!isSending &&
|
|
||||||
subject.trim() &&
|
|
||||||
emailContent.trim() &&
|
|
||||||
toEmail &&
|
|
||||||
toEmail.trim() !== "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col overflow-hidden">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Mail className="text-primary h-5 w-5" />
|
|
||||||
Send Invoice Email
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Compose and preview your invoice email before sending to{" "}
|
|
||||||
{invoice?.client?.name ?? "client"}.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* Warning for missing email */}
|
|
||||||
{(!toEmail || toEmail.trim() === "") && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
This client doesn'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,29 +2,39 @@
|
|||||||
|
|
||||||
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";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
||||||
|
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 [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="md:hidden fixed top-0 left-0 right-0 h-16 bg-background/80 backdrop-blur-md border-b z-50 px-4 flex items-center">
|
<div className="dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden">
|
||||||
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
|
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant="outline" size="icon" className="h-10 w-10 bg-background shadow-sm" suppressHydrationWarning>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="bg-background h-10 w-10 shadow-sm"
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
<Menu className="h-5 w-5" />
|
<Menu className="h-5 w-5" />
|
||||||
<span className="sr-only">Toggle menu</span>
|
<span className="sr-only">Toggle menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -33,7 +43,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
|||||||
<div className="ml-4 flex items-center gap-2">
|
<div className="ml-4 flex items-center gap-2">
|
||||||
<Logo size="sm" />
|
<Logo size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<SheetContent side="left" className="p-0 w-72">
|
<SheetContent side="left" className="w-72 p-0">
|
||||||
<div className="sr-only">
|
<div className="sr-only">
|
||||||
<h2 id="mobile-nav-title">Navigation Menu</h2>
|
<h2 id="mobile-nav-title">Navigation Menu</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,19 +56,20 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
|||||||
<main
|
<main
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 min-h-screen min-w-0 transition-all duration-300 ease-in-out",
|
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out",
|
||||||
// Desktop margins based on collapsed state
|
|
||||||
"md:ml-0",
|
"md:ml-0",
|
||||||
// Sidebar is fixed at left: 1rem (16px), width: 16rem (256px) or 4rem (64px)
|
sidebarStyle === "floating"
|
||||||
// We need margin-left = left + width + gap
|
? isCollapsed
|
||||||
// Expanded: 16px + 256px + 16px (gap) = 288px (18rem)
|
? "md:ml-24"
|
||||||
// Collapsed: 16px + 64px + 16px (gap) = 96px (6rem)
|
: "md:ml-[18rem]"
|
||||||
isCollapsed ? "md:ml-24" : "md:ml-[18rem]"
|
: isCollapsed
|
||||||
|
? "md:ml-16"
|
||||||
|
: "md:ml-64",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="p-4 pt-16 md:pt-4">
|
<div className="dashboard-content-shell p-4 pt-16 md:pt-4">
|
||||||
{/* Mobile header spacer is handled by pt-16 on mobile */}
|
{/* Mobile header spacer is handled by pt-16 on mobile */}
|
||||||
<div className="md:hidden mb-4">
|
<div className="mb-4 md:hidden">
|
||||||
{/* Mobile Breadcrumbs could go here or be part of the page */}
|
{/* Mobile Breadcrumbs could go here or be part of the page */}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||||
|
import { useSidebar } from "~/components/layout/sidebar-provider";
|
||||||
|
|
||||||
interface FloatingActionBarProps {
|
interface FloatingActionBarProps {
|
||||||
/** Content to display on the left side */
|
/** Content to display on the left side */
|
||||||
@@ -13,74 +15,38 @@ interface FloatingActionBarProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { useSidebar } from "~/components/layout/sidebar-provider";
|
|
||||||
|
|
||||||
export function FloatingActionBar({
|
export function FloatingActionBar({
|
||||||
leftContent,
|
leftContent,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: FloatingActionBarProps) {
|
}: FloatingActionBarProps) {
|
||||||
const [isDocked, setIsDocked] = useState(false);
|
|
||||||
const { isCollapsed } = useSidebar();
|
const { isCollapsed } = useSidebar();
|
||||||
|
const { sidebarStyle } = useAppearance();
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
// Check if we're truly at the bottom of the page
|
|
||||||
const scrollHeight = document.documentElement.scrollHeight;
|
|
||||||
const scrollTop = document.documentElement.scrollTop;
|
|
||||||
const clientHeight = document.documentElement.clientHeight;
|
|
||||||
const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
|
|
||||||
|
|
||||||
// Only dock when we're within 50px of the actual bottom AND there's content to scroll
|
|
||||||
const hasScrollableContent = scrollHeight > clientHeight;
|
|
||||||
const shouldDock = hasScrollableContent && distanceFromBottom <= 50;
|
|
||||||
|
|
||||||
// If content is too small, keep it at bottom of viewport
|
|
||||||
const contentTooSmall = scrollHeight <= clientHeight + 200;
|
|
||||||
|
|
||||||
setIsDocked(shouldDock && !contentTooSmall);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
||||||
handleScroll(); // Check initial state
|
|
||||||
|
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
// Base positioning - always at bottom
|
"pb-safe-area-inset-bottom fixed right-0 bottom-4 left-0 z-50 transition-all duration-300 ease-in-out",
|
||||||
"fixed right-0 z-50 transition-all duration-300 ease-in-out",
|
sidebarStyle === "floating"
|
||||||
// Safe area and sidebar adjustments
|
? isCollapsed
|
||||||
"pb-safe-area-inset-bottom left-0",
|
? "md:left-24"
|
||||||
isCollapsed ? "md:left-24" : "md:left-[18rem]",
|
: "md:left-[18rem]"
|
||||||
// Conditional centering based on dock state
|
: isCollapsed
|
||||||
isDocked ? "flex justify-center" : "",
|
? "md:left-16"
|
||||||
// Dynamic bottom positioning
|
: "md:left-64",
|
||||||
isDocked ? "bottom-4" : "bottom-0",
|
|
||||||
// Add entrance animation
|
|
||||||
"animate-slide-in-bottom",
|
"animate-slide-in-bottom",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Content container - full width when floating, content width when docked */}
|
<div className="w-full px-4 transition-transform duration-300">
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"w-full transition-transform duration-300",
|
|
||||||
isDocked ? "mx-auto mb-0 px-4" : "mb-4 px-4",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Card className="hover-lift bg-card border-border border shadow-lg">
|
<Card className="hover-lift bg-card border-border border shadow-lg">
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm:p-4">
|
||||||
{/* Left content */}
|
|
||||||
{leftContent && (
|
{leftContent && (
|
||||||
<div className="text-card-foreground animate-fade-in flex flex-1 items-center gap-3">
|
<div className="text-card-foreground animate-fade-in flex flex-1 items-center gap-3">
|
||||||
{leftContent}
|
{leftContent}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Right actions */}
|
|
||||||
<div className="animate-fade-in animate-delay-100 flex items-center gap-2 sm:gap-3">
|
<div className="animate-fade-in animate-delay-100 flex items-center gap-2 sm:gap-3">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ import { cn } from "~/lib/utils";
|
|||||||
|
|
||||||
export function MotionBackground() {
|
export function MotionBackground() {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 -z-50 overflow-hidden pointer-events-none bg-background">
|
<div className="bg-background pointer-events-none fixed inset-0 -z-50 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-[-50%] w-[200%] h-[200%]",
|
"absolute inset-[-50%] h-[200%] w-[200%]",
|
||||||
"bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]",
|
"bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]",
|
||||||
"from-[oklch(var(--primary)/0.15)] via-transparent to-transparent",
|
"from-[oklch(var(--primary)/0.15)] via-transparent to-transparent",
|
||||||
"animate-subtle-spin opacity-100"
|
"animate-subtle-spin opacity-100",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-[-50%] w-[200%] h-[200%]",
|
"absolute inset-[-50%] h-[200%] w-[200%]",
|
||||||
"bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]",
|
"bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]",
|
||||||
"from-[oklch(var(--accent)/0.15)] via-transparent to-transparent",
|
"from-[oklch(var(--accent)/0.15)] via-transparent to-transparent",
|
||||||
"animate-subtle-wave opacity-100"
|
"animate-subtle-wave opacity-100",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-[url('/noise.svg')] opacity-[0.02] mix-blend-overlay" />
|
<div className="absolute inset-0 bg-[url('/noise.svg')] opacity-[0.02] mix-blend-overlay" />
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export function Navbar() {
|
interface NavbarProps {
|
||||||
|
allowRegistration?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Navbar({ allowRegistration = true }: NavbarProps) {
|
||||||
const { data: session, isPending } = authClient.useSession();
|
const { data: session, isPending } = authClient.useSession();
|
||||||
// const session = { user: null } as any; const isPending = false;
|
// const session = { user: null } as any; const isPending = false;
|
||||||
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
|
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
|
||||||
@@ -63,6 +67,7 @@ export function Navbar() {
|
|||||||
Sign In
|
Sign In
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
{allowRegistration && (
|
||||||
<Link href="/auth/register">
|
<Link href="/auth/register">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -72,6 +77,7 @@ export function Navbar() {
|
|||||||
Register
|
Register
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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="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="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="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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -14,15 +14,11 @@ const SidebarContext = React.createContext<SidebarContextType | undefined>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [isCollapsed, setIsCollapsed] = React.useState(false);
|
const [isCollapsed, setIsCollapsed] = React.useState(() => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
// Persist state if needed, for now just local state
|
|
||||||
React.useEffect(() => {
|
|
||||||
const saved = localStorage.getItem("sidebar-collapsed");
|
const saved = localStorage.getItem("sidebar-collapsed");
|
||||||
if (saved) {
|
return saved ? (JSON.parse(saved) as boolean) : false;
|
||||||
setIsCollapsed(JSON.parse(saved) as boolean);
|
});
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleCollapse = React.useCallback(() => {
|
const toggleCollapse = React.useCallback(() => {
|
||||||
setIsCollapsed((prev) => {
|
setIsCollapsed((prev) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
} from "~/components/ui/dropdown-menu";
|
} from "~/components/ui/dropdown-menu";
|
||||||
import { getGravatarUrl } from "~/lib/gravatar";
|
import { getGravatarUrl } from "~/lib/gravatar";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||||
|
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
mobile?: boolean;
|
mobile?: boolean;
|
||||||
@@ -36,6 +38,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
|
|||||||
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 { isCollapsed, toggleCollapse } = useSidebar();
|
const { isCollapsed, toggleCollapse } = useSidebar();
|
||||||
|
const { sidebarStyle } = useAppearance();
|
||||||
|
|
||||||
// If mobile, always expanded
|
// If mobile, always expanded
|
||||||
const collapsed = mobile ? false : isCollapsed;
|
const collapsed = mobile ? false : isCollapsed;
|
||||||
@@ -44,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" />
|
||||||
@@ -61,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>
|
||||||
)}
|
)}
|
||||||
@@ -82,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>
|
||||||
@@ -104,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" />
|
||||||
@@ -125,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>
|
||||||
@@ -156,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>
|
||||||
@@ -175,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 />
|
||||||
@@ -190,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
|
||||||
@@ -204,19 +259,17 @@ 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 (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-4 bottom-4 left-4 z-30 hidden md:flex flex-col",
|
"fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex",
|
||||||
"bg-background/80 backdrop-blur-xl border-border/50 border shadow-xl rounded-3xl transition-all duration-300 ease-in-out",
|
sidebarStyle === "floating"
|
||||||
isCollapsed ? "w-16" : "w-64"
|
? "border-border/50 bg-background/80 top-4 bottom-4 left-4 rounded-3xl border shadow-xl backdrop-blur-xl"
|
||||||
|
: "border-border bg-background top-0 bottom-0 left-0 rounded-none border-r shadow-none",
|
||||||
|
isCollapsed ? "w-16" : "w-64",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{SidebarContent}
|
{SidebarContent}
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ export function Breadcrumbs() {
|
|||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<nav className="flex items-center text-sm text-muted-foreground" aria-label="Breadcrumb">
|
<nav
|
||||||
|
className="text-muted-foreground flex items-center text-sm"
|
||||||
|
aria-label="Breadcrumb"
|
||||||
|
>
|
||||||
{crumbs.map((crumb, i) => (
|
{crumbs.map((crumb, i) => (
|
||||||
<span key={crumb.href} className="flex items-center">
|
<span key={crumb.href} className="flex items-center">
|
||||||
{i > 0 && <ChevronRight className="mx-2 h-4 w-4 text-gray-300" />}
|
{i > 0 && <ChevronRight className="mx-2 h-4 w-4 text-gray-300" />}
|
||||||
{i < crumbs.length - 1 ? (
|
{i < crumbs.length - 1 ? (
|
||||||
<Link href={crumb.href} className="hover:underline text-gray-500">
|
<Link href={crumb.href} className="text-gray-500 hover:underline">
|
||||||
{crumb.name}
|
{crumb.name}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
|
|||||||
aria-current={
|
aria-current={
|
||||||
pathname === link.href ? "page" : undefined
|
pathname === link.href ? "page" : undefined
|
||||||
}
|
}
|
||||||
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${pathname === link.href
|
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||||
|
pathname === link.href
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-foreground hover:bg-muted"
|
: "text-foreground hover:bg-muted"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -205,9 +205,9 @@ export function AnimationPreferencesProvider({
|
|||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const stored = readLocalStorage();
|
const stored = readLocalStorage();
|
||||||
|
|
||||||
const systemReduced =
|
const systemReduced = window.matchMedia?.(
|
||||||
window.matchMedia &&
|
"(prefers-reduced-motion: reduce)",
|
||||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
).matches;
|
||||||
|
|
||||||
const finalPrefers =
|
const finalPrefers =
|
||||||
stored?.prefersReducedMotion ??
|
stored?.prefersReducedMotion ??
|
||||||
@@ -220,6 +220,7 @@ export function AnimationPreferencesProvider({
|
|||||||
DEFAULT_SPEED,
|
DEFAULT_SPEED,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Hydrate preferences from localStorage/system settings on mount.
|
||||||
setPrefersReducedMotion(finalPrefers);
|
setPrefersReducedMotion(finalPrefers);
|
||||||
setAnimationSpeedMultiplier(finalSpeed);
|
setAnimationSpeedMultiplier(finalSpeed);
|
||||||
applyPreferencesToDOM({
|
applyPreferencesToDOM({
|
||||||
@@ -279,7 +280,8 @@ export function AnimationPreferencesProvider({
|
|||||||
// Optionally sync to server
|
// Optionally sync to server
|
||||||
const shouldSync = opts?.sync ?? autoSync;
|
const shouldSync = opts?.sync ?? autoSync;
|
||||||
|
|
||||||
if (shouldSync && serverPrefs) { // If serverPrefs exists, user is authenticated
|
if (shouldSync && serverPrefs) {
|
||||||
|
// If serverPrefs exists, user is authenticated
|
||||||
pendingSyncRef.current = {
|
pendingSyncRef.current = {
|
||||||
prefersReducedMotion: patch.prefersReducedMotion,
|
prefersReducedMotion: patch.prefersReducedMotion,
|
||||||
animationSpeedMultiplier: patch.animationSpeedMultiplier,
|
animationSpeedMultiplier: patch.animationSpeedMultiplier,
|
||||||
@@ -334,6 +336,7 @@ export function AnimationPreferencesProvider({
|
|||||||
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
|
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
|
||||||
|
|
||||||
if (localIsDefault || differs) {
|
if (localIsDefault || differs) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reconcile loaded server preferences once after query hydration.
|
||||||
performUpdate(
|
performUpdate(
|
||||||
{
|
{
|
||||||
prefersReducedMotion: serverPrefs.prefersReducedMotion,
|
prefersReducedMotion: serverPrefs.prefersReducedMotion,
|
||||||
@@ -402,9 +405,15 @@ export function useAnimationPreferences(): AnimationPreferencesContextValue {
|
|||||||
return {
|
return {
|
||||||
prefersReducedMotion: false,
|
prefersReducedMotion: false,
|
||||||
animationSpeedMultiplier: 1,
|
animationSpeedMultiplier: 1,
|
||||||
updatePreferences: () => { /* no-op fallback */ },
|
updatePreferences: () => {
|
||||||
setPrefersReducedMotion: () => { /* no-op fallback */ },
|
/* no-op fallback */
|
||||||
setAnimationSpeedMultiplier: () => { /* no-op fallback */ },
|
},
|
||||||
|
setPrefersReducedMotion: () => {
|
||||||
|
/* no-op fallback */
|
||||||
|
},
|
||||||
|
setAnimationSpeedMultiplier: () => {
|
||||||
|
/* no-op fallback */
|
||||||
|
},
|
||||||
isUpdating: false,
|
isUpdating: false,
|
||||||
lastSyncedAt: null,
|
lastSyncedAt: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
fallbackAppearance,
|
||||||
|
isColorMode,
|
||||||
|
isColorTheme,
|
||||||
|
isFontPreference,
|
||||||
|
isHslChannels,
|
||||||
|
isInterfaceTheme,
|
||||||
|
isPdfTemplate,
|
||||||
|
isRadiusPreference,
|
||||||
|
isSidebarStyle,
|
||||||
|
type PdfTemplate,
|
||||||
|
} from "~/lib/appearance";
|
||||||
|
import {
|
||||||
|
defaultBodyFontPreference,
|
||||||
|
defaultHeadingFontPreference,
|
||||||
|
defaultInterfaceTheme,
|
||||||
|
defaultRadiusPreference,
|
||||||
|
defaultSidebarStyle,
|
||||||
|
brand as defaultBrand,
|
||||||
|
type ColorMode,
|
||||||
|
type ColorTheme,
|
||||||
|
type FontPreference,
|
||||||
|
type InterfaceTheme,
|
||||||
|
type RadiusPreference,
|
||||||
|
type SidebarStyle,
|
||||||
|
} from "~/lib/branding";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
type AppearancePreferences = {
|
||||||
|
interfaceTheme: InterfaceTheme;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppearancePatch = Partial<AppearancePreferences>;
|
||||||
|
|
||||||
|
type ServerAppearance = {
|
||||||
|
interfaceTheme: InterfaceTheme;
|
||||||
|
bodyFontPreference: FontPreference;
|
||||||
|
headingFontPreference: FontPreference;
|
||||||
|
radiusPreference: RadiusPreference;
|
||||||
|
sidebarStyle: SidebarStyle;
|
||||||
|
theme: ColorMode;
|
||||||
|
colorTheme: ColorTheme;
|
||||||
|
customColor?: string;
|
||||||
|
brandName: string;
|
||||||
|
brandTagline: string;
|
||||||
|
brandLogoText: string;
|
||||||
|
brandIcon: string;
|
||||||
|
pdfTemplate: PdfTemplate;
|
||||||
|
pdfAccentColor: string;
|
||||||
|
pdfFooterText: string;
|
||||||
|
pdfShowLogo: boolean;
|
||||||
|
pdfShowPageNumbers: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppearanceContextValue = AppearancePreferences & {
|
||||||
|
updateAppearance: (patch: AppearancePatch) => void;
|
||||||
|
updateAppearanceDebounced: (patch: AppearancePatch) => void;
|
||||||
|
isUpdating: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = "bv.appearance";
|
||||||
|
|
||||||
|
const defaultAppearance: AppearancePreferences = {
|
||||||
|
interfaceTheme: defaultInterfaceTheme,
|
||||||
|
bodyFontPreference: defaultBodyFontPreference,
|
||||||
|
headingFontPreference: defaultHeadingFontPreference,
|
||||||
|
radiusPreference: defaultRadiusPreference,
|
||||||
|
sidebarStyle: defaultSidebarStyle,
|
||||||
|
colorMode: fallbackAppearance.colorMode,
|
||||||
|
colorTheme: fallbackAppearance.colorTheme,
|
||||||
|
brandName: defaultBrand.name,
|
||||||
|
brandTagline: defaultBrand.tagline,
|
||||||
|
brandLogoText: defaultBrand.logoText,
|
||||||
|
brandIcon: defaultBrand.icon,
|
||||||
|
pdfTemplate: fallbackAppearance.pdfTemplate,
|
||||||
|
pdfAccentColor: fallbackAppearance.pdfAccentColor,
|
||||||
|
pdfFooterText: fallbackAppearance.pdfFooterText,
|
||||||
|
pdfShowLogo: fallbackAppearance.pdfShowLogo,
|
||||||
|
pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
|
||||||
|
|
||||||
|
function getServerAppearancePatch(
|
||||||
|
serverAppearance: ServerAppearance,
|
||||||
|
): AppearancePatch {
|
||||||
|
return {
|
||||||
|
interfaceTheme: serverAppearance.interfaceTheme,
|
||||||
|
bodyFontPreference: serverAppearance.bodyFontPreference,
|
||||||
|
headingFontPreference: serverAppearance.headingFontPreference,
|
||||||
|
radiusPreference: serverAppearance.radiusPreference,
|
||||||
|
sidebarStyle: serverAppearance.sidebarStyle,
|
||||||
|
colorMode: serverAppearance.theme,
|
||||||
|
colorTheme: serverAppearance.colorTheme,
|
||||||
|
customColor: serverAppearance.customColor,
|
||||||
|
brandName: serverAppearance.brandName,
|
||||||
|
brandTagline: serverAppearance.brandTagline,
|
||||||
|
brandLogoText: serverAppearance.brandLogoText,
|
||||||
|
brandIcon: serverAppearance.brandIcon,
|
||||||
|
pdfTemplate: serverAppearance.pdfTemplate,
|
||||||
|
pdfAccentColor: serverAppearance.pdfAccentColor,
|
||||||
|
pdfFooterText: serverAppearance.pdfFooterText,
|
||||||
|
pdfShowLogo: serverAppearance.pdfShowLogo,
|
||||||
|
pdfShowPageNumbers: serverAppearance.pdfShowPageNumbers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredAppearance(): Partial<AppearancePreferences> | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
interfaceTheme: isInterfaceTheme(parsed.interfaceTheme)
|
||||||
|
? parsed.interfaceTheme
|
||||||
|
: undefined,
|
||||||
|
bodyFontPreference: isFontPreference(parsed.bodyFontPreference)
|
||||||
|
? parsed.bodyFontPreference
|
||||||
|
: isFontPreference(parsed.fontPreference)
|
||||||
|
? parsed.fontPreference
|
||||||
|
: undefined,
|
||||||
|
headingFontPreference: isFontPreference(parsed.headingFontPreference)
|
||||||
|
? parsed.headingFontPreference
|
||||||
|
: isFontPreference(parsed.fontPreference)
|
||||||
|
? parsed.fontPreference
|
||||||
|
: undefined,
|
||||||
|
radiusPreference: isRadiusPreference(parsed.radiusPreference)
|
||||||
|
? parsed.radiusPreference
|
||||||
|
: undefined,
|
||||||
|
sidebarStyle: isSidebarStyle(parsed.sidebarStyle)
|
||||||
|
? parsed.sidebarStyle
|
||||||
|
: undefined,
|
||||||
|
colorMode: isColorMode(parsed.colorMode) ? parsed.colorMode : undefined,
|
||||||
|
colorTheme: isColorTheme(parsed.colorTheme)
|
||||||
|
? parsed.colorTheme
|
||||||
|
: undefined,
|
||||||
|
customColor: isHslChannels(parsed.customColor)
|
||||||
|
? parsed.customColor
|
||||||
|
: undefined,
|
||||||
|
brandName:
|
||||||
|
typeof parsed.brandName === "string" ? parsed.brandName : undefined,
|
||||||
|
brandTagline:
|
||||||
|
typeof parsed.brandTagline === "string"
|
||||||
|
? parsed.brandTagline
|
||||||
|
: undefined,
|
||||||
|
brandLogoText:
|
||||||
|
typeof parsed.brandLogoText === "string"
|
||||||
|
? parsed.brandLogoText
|
||||||
|
: undefined,
|
||||||
|
brandIcon:
|
||||||
|
typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined,
|
||||||
|
pdfTemplate: isPdfTemplate(parsed.pdfTemplate)
|
||||||
|
? parsed.pdfTemplate
|
||||||
|
: undefined,
|
||||||
|
pdfAccentColor:
|
||||||
|
typeof parsed.pdfAccentColor === "string"
|
||||||
|
? parsed.pdfAccentColor
|
||||||
|
: undefined,
|
||||||
|
pdfFooterText:
|
||||||
|
typeof parsed.pdfFooterText === "string"
|
||||||
|
? parsed.pdfFooterText
|
||||||
|
: undefined,
|
||||||
|
pdfShowLogo:
|
||||||
|
typeof parsed.pdfShowLogo === "boolean"
|
||||||
|
? parsed.pdfShowLogo
|
||||||
|
: undefined,
|
||||||
|
pdfShowPageNumbers:
|
||||||
|
typeof parsed.pdfShowPageNumbers === "boolean"
|
||||||
|
? parsed.pdfShowPageNumbers
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredAppearance(prefs: AppearancePreferences) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable in private browsing or locked-down contexts.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAppearance(prefs: AppearancePreferences) {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.dataset.interfaceTheme = prefs.interfaceTheme;
|
||||||
|
root.dataset.bodyFont = prefs.bodyFontPreference;
|
||||||
|
root.dataset.headingFont = prefs.headingFontPreference;
|
||||||
|
root.dataset.radius = prefs.radiusPreference;
|
||||||
|
root.dataset.sidebarStyle = prefs.sidebarStyle;
|
||||||
|
root.dataset.colorMode = prefs.colorMode;
|
||||||
|
root.dataset.colorTheme = prefs.colorTheme;
|
||||||
|
|
||||||
|
root.classList.toggle("dark", prefs.colorMode === "dark");
|
||||||
|
|
||||||
|
if (prefs.customColor) {
|
||||||
|
root.style.setProperty("--custom-primary", prefs.customColor);
|
||||||
|
} else {
|
||||||
|
root.style.removeProperty("--custom-primary");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppearanceProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [appearance, setAppearance] =
|
||||||
|
useState<AppearancePreferences>(defaultAppearance);
|
||||||
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const updateMutation = api.settings.updateTheme.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.settings.getTheme.invalidate();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
const cachedAppearance = utils.settings.getTheme.getData();
|
||||||
|
const fallback = cachedAppearance
|
||||||
|
? {
|
||||||
|
...defaultAppearance,
|
||||||
|
...getServerAppearancePatch(cachedAppearance),
|
||||||
|
}
|
||||||
|
: defaultAppearance;
|
||||||
|
|
||||||
|
setAppearance(fallback);
|
||||||
|
applyAppearance(fallback);
|
||||||
|
writeStoredAppearance(fallback);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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, {
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedAppearance = readStoredAppearance();
|
||||||
|
if (!storedAppearance) return;
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!serverAppearance) return;
|
||||||
|
const next = getServerAppearancePatch(serverAppearance);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setAppearance((prev) => ({ ...prev, ...next }));
|
||||||
|
}, [serverAppearance]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyAppearance(appearance);
|
||||||
|
writeStoredAppearance(appearance);
|
||||||
|
}, [appearance]);
|
||||||
|
|
||||||
|
const updateAppearance = useCallback(
|
||||||
|
(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) => {
|
||||||
|
const next = { ...prev, ...patch };
|
||||||
|
applyAppearance(next);
|
||||||
|
writeStoredAppearance(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
persistAppearance(patch);
|
||||||
|
},
|
||||||
|
[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>(
|
||||||
|
() => ({
|
||||||
|
...appearance,
|
||||||
|
updateAppearance,
|
||||||
|
updateAppearanceDebounced,
|
||||||
|
isUpdating: updateMutation.isPending,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
appearance,
|
||||||
|
updateAppearance,
|
||||||
|
updateAppearanceDebounced,
|
||||||
|
updateMutation.isPending,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppearanceContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AppearanceContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppearance() {
|
||||||
|
const ctx = useContext(AppearanceContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAppearance must be used within an AppearanceProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
import { buttonVariants } from "~/components/ui/button"
|
import { buttonVariants } from "~/components/ui/button";
|
||||||
|
|
||||||
function AlertDialog({
|
function AlertDialog({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogTrigger({
|
function AlertDialogTrigger({
|
||||||
@@ -17,7 +17,7 @@ function AlertDialogTrigger({
|
|||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogPortal({
|
function AlertDialogPortal({
|
||||||
@@ -25,7 +25,7 @@ function AlertDialogPortal({
|
|||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogOverlay({
|
function AlertDialogOverlay({
|
||||||
@@ -37,11 +37,11 @@ function AlertDialogOverlay({
|
|||||||
data-slot="alert-dialog-overlay"
|
data-slot="alert-dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogContent({
|
function AlertDialogContent({
|
||||||
@@ -55,12 +55,12 @@ function AlertDialogContent({
|
|||||||
data-slot="alert-dialog-content"
|
data-slot="alert-dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</AlertDialogPortal>
|
</AlertDialogPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogHeader({
|
function AlertDialogHeader({
|
||||||
@@ -73,7 +73,7 @@ function AlertDialogHeader({
|
|||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogFooter({
|
function AlertDialogFooter({
|
||||||
@@ -85,11 +85,11 @@ function AlertDialogFooter({
|
|||||||
data-slot="alert-dialog-footer"
|
data-slot="alert-dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogTitle({
|
function AlertDialogTitle({
|
||||||
@@ -102,7 +102,7 @@ function AlertDialogTitle({
|
|||||||
className={cn("text-lg font-semibold", className)}
|
className={cn("text-lg font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogDescription({
|
function AlertDialogDescription({
|
||||||
@@ -115,7 +115,7 @@ function AlertDialogDescription({
|
|||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogAction({
|
function AlertDialogAction({
|
||||||
@@ -127,7 +127,7 @@ function AlertDialogAction({
|
|||||||
className={cn(buttonVariants(), className)}
|
className={cn(buttonVariants(), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogCancel({
|
function AlertDialogCancel({
|
||||||
@@ -139,7 +139,7 @@ function AlertDialogCancel({
|
|||||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -154,4 +154,4 @@ export {
|
|||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
const Avatar = React.forwardRef<
|
const Avatar = React.forwardRef<
|
||||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
@@ -13,12 +13,12 @@ const Avatar = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||||
|
|
||||||
const AvatarImage = React.forwardRef<
|
const AvatarImage = React.forwardRef<
|
||||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
@@ -29,8 +29,8 @@ const AvatarImage = React.forwardRef<
|
|||||||
className={cn("aspect-square h-full w-full", className)}
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
const AvatarFallback = React.forwardRef<
|
const AvatarFallback = React.forwardRef<
|
||||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
@@ -39,12 +39,12 @@ const AvatarFallback = React.forwardRef<
|
|||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
"bg-muted flex h-full w-full items-center justify-center rounded-full",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||||
|
|
||||||
export { Avatar, AvatarImage, AvatarFallback }
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react";
|
||||||
import { DayPicker, getDefaultClassNames, type DayButton } from "react-day-picker"
|
import {
|
||||||
|
DayPicker,
|
||||||
|
getDefaultClassNames,
|
||||||
|
type DayButton,
|
||||||
|
} from "react-day-picker";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
import { Button, buttonVariants } from "~/components/ui/button"
|
import { Button, buttonVariants } from "~/components/ui/button";
|
||||||
|
|
||||||
function Calendar({
|
function Calendar({
|
||||||
className,
|
className,
|
||||||
@@ -21,9 +25,9 @@ function Calendar({
|
|||||||
components,
|
components,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DayPicker> & {
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||||
}) {
|
}) {
|
||||||
const defaultClassNames = getDefaultClassNames()
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DayPicker
|
<DayPicker
|
||||||
@@ -32,7 +36,7 @@ function Calendar({
|
|||||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
captionLayout={captionLayout}
|
captionLayout={captionLayout}
|
||||||
formatters={{
|
formatters={{
|
||||||
@@ -44,86 +48,88 @@ function Calendar({
|
|||||||
root: cn("w-fit", defaultClassNames.root),
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
months: cn(
|
months: cn(
|
||||||
"flex gap-4 flex-col md:flex-row relative",
|
"flex gap-4 flex-col md:flex-row relative",
|
||||||
defaultClassNames.months
|
defaultClassNames.months,
|
||||||
),
|
),
|
||||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||||
nav: cn(
|
nav: cn(
|
||||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||||
defaultClassNames.nav
|
defaultClassNames.nav,
|
||||||
),
|
),
|
||||||
button_previous: cn(
|
button_previous: cn(
|
||||||
buttonVariants({ variant: buttonVariant }),
|
buttonVariants({ variant: buttonVariant }),
|
||||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
defaultClassNames.button_previous
|
defaultClassNames.button_previous,
|
||||||
),
|
),
|
||||||
button_next: cn(
|
button_next: cn(
|
||||||
buttonVariants({ variant: buttonVariant }),
|
buttonVariants({ variant: buttonVariant }),
|
||||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
defaultClassNames.button_next
|
defaultClassNames.button_next,
|
||||||
),
|
),
|
||||||
month_caption: cn(
|
month_caption: cn(
|
||||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||||
defaultClassNames.month_caption
|
defaultClassNames.month_caption,
|
||||||
),
|
),
|
||||||
dropdowns: cn(
|
dropdowns: cn(
|
||||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||||
defaultClassNames.dropdowns
|
defaultClassNames.dropdowns,
|
||||||
),
|
),
|
||||||
dropdown_root: cn(
|
dropdown_root: cn(
|
||||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||||
defaultClassNames.dropdown_root
|
defaultClassNames.dropdown_root,
|
||||||
),
|
),
|
||||||
dropdown: cn(
|
dropdown: cn(
|
||||||
"absolute bg-popover inset-0 opacity-0",
|
"absolute bg-popover inset-0 opacity-0",
|
||||||
defaultClassNames.dropdown
|
defaultClassNames.dropdown,
|
||||||
),
|
),
|
||||||
caption_label: cn(
|
caption_label: cn(
|
||||||
"select-none font-medium",
|
"select-none font-medium",
|
||||||
captionLayout === "label"
|
captionLayout === "label"
|
||||||
? "text-sm"
|
? "text-sm"
|
||||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||||
defaultClassNames.caption_label
|
defaultClassNames.caption_label,
|
||||||
),
|
),
|
||||||
table: "w-full border-collapse",
|
table: "w-full border-collapse",
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||||
defaultClassNames.weekday
|
defaultClassNames.weekday,
|
||||||
),
|
),
|
||||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||||
week_number_header: cn(
|
week_number_header: cn(
|
||||||
"select-none w-(--cell-size)",
|
"select-none w-(--cell-size)",
|
||||||
defaultClassNames.week_number_header
|
defaultClassNames.week_number_header,
|
||||||
),
|
),
|
||||||
week_number: cn(
|
week_number: cn(
|
||||||
"text-[0.8rem] select-none text-muted-foreground",
|
"text-[0.8rem] select-none text-muted-foreground",
|
||||||
defaultClassNames.week_number
|
defaultClassNames.week_number,
|
||||||
),
|
),
|
||||||
day: cn(
|
day: cn(
|
||||||
"relative w-full h-full p-0 text-center group/day aspect-square select-none",
|
"relative w-full h-full p-0 text-center group/day aspect-square select-none",
|
||||||
props.mode !== "single" && "[&:last-child[data-selected=true]_button]:rounded-r-md",
|
props.mode !== "single" &&
|
||||||
props.mode !== "single" && (props.showWeekNumber
|
"[&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||||
|
props.mode !== "single" &&
|
||||||
|
(props.showWeekNumber
|
||||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md"),
|
: "[&:first-child[data-selected=true]_button]:rounded-l-md"),
|
||||||
defaultClassNames.day
|
defaultClassNames.day,
|
||||||
),
|
),
|
||||||
range_start: cn(
|
range_start: cn(
|
||||||
"rounded-l-md bg-accent",
|
"rounded-l-md bg-accent",
|
||||||
defaultClassNames.range_start
|
defaultClassNames.range_start,
|
||||||
),
|
),
|
||||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||||
today: cn(
|
today: cn(
|
||||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
defaultClassNames.today
|
defaultClassNames.today,
|
||||||
),
|
),
|
||||||
outside: cn(
|
outside: cn(
|
||||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
defaultClassNames.outside
|
defaultClassNames.outside,
|
||||||
),
|
),
|
||||||
disabled: cn(
|
disabled: cn(
|
||||||
"text-muted-foreground opacity-50",
|
"text-muted-foreground opacity-50",
|
||||||
defaultClassNames.disabled
|
defaultClassNames.disabled,
|
||||||
),
|
),
|
||||||
hidden: cn("invisible", defaultClassNames.hidden),
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
...classNames,
|
...classNames,
|
||||||
@@ -137,13 +143,13 @@ function Calendar({
|
|||||||
className={cn(className)}
|
className={cn(className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
Chevron: ({ className, orientation, ...props }) => {
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
if (orientation === "left") {
|
if (orientation === "left") {
|
||||||
return (
|
return (
|
||||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orientation === "right") {
|
if (orientation === "right") {
|
||||||
@@ -152,12 +158,12 @@ function Calendar({
|
|||||||
className={cn("size-4", className)}
|
className={cn("size-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
DayButton: CalendarDayButton,
|
DayButton: CalendarDayButton,
|
||||||
WeekNumber: ({ children, ...props }) => {
|
WeekNumber: ({ children, ...props }) => {
|
||||||
@@ -167,13 +173,13 @@ function Calendar({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
...components,
|
...components,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalendarDayButton({
|
function CalendarDayButton({
|
||||||
@@ -182,12 +188,12 @@ function CalendarDayButton({
|
|||||||
modifiers,
|
modifiers,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DayButton>) {
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
const defaultClassNames = getDefaultClassNames()
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
const ref = React.useRef<HTMLButtonElement>(null)
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (modifiers.focused) ref.current?.focus()
|
if (modifiers.focused) ref.current?.focus();
|
||||||
}, [modifiers.focused])
|
}, [modifiers.focused]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -207,11 +213,11 @@ function CalendarDayButton({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||||
defaultClassNames.day,
|
defaultClassNames.day,
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Calendar, CalendarDayButton }
|
export { Calendar, CalendarDayButton };
|
||||||
|
|||||||
@@ -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,9 +3,20 @@
|
|||||||
import { motion, useSpring, useTransform } from "framer-motion";
|
import { motion, useSpring, useTransform } from "framer-motion";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export function CountUp({ value, prefix = "", suffix = "" }: { value: number, prefix?: string, suffix?: string }) {
|
export function CountUp({
|
||||||
|
value,
|
||||||
|
prefix = "",
|
||||||
|
suffix = "",
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
}) {
|
||||||
const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 });
|
const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 });
|
||||||
const display = useTransform(spring, (current) => `${prefix}${current.toFixed(2)}${suffix}`);
|
const display = useTransform(
|
||||||
|
spring,
|
||||||
|
(current) => `${prefix}${current.toFixed(2)}${suffix}`,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
spring.set(value);
|
spring.set(value);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export function DatePicker({
|
|||||||
: "w-full md:w-32 md:min-w-32";
|
: "w-full md:w-32 md:min-w-32";
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
|
||||||
setValue(formatDate(date));
|
setValue(formatDate(date));
|
||||||
setMonth(date);
|
setMonth(date);
|
||||||
}, [date]);
|
}, [date]);
|
||||||
@@ -77,7 +78,12 @@ export function DatePicker({
|
|||||||
value={value}
|
value={value}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn("bg-background pr-10", sizeClasses[size], "w-full", inputClassName)}
|
className={cn(
|
||||||
|
"bg-background pr-10",
|
||||||
|
sizeClasses[size],
|
||||||
|
"w-full",
|
||||||
|
inputClassName,
|
||||||
|
)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setValue(e.target.value);
|
setValue(e.target.value);
|
||||||
const parsedDate = parseDate(e.target.value);
|
const parsedDate = parseDate(e.target.value);
|
||||||
@@ -98,13 +104,16 @@ export function DatePicker({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="absolute top-1/2 right-2 size-6 p-0 -translate-y-1/2 text-primary/80 hover:text-primary transition-colors z-20"
|
className="text-primary/80 hover:text-primary absolute top-1/2 right-2 z-20 size-6 -translate-y-1/2 p-0 transition-colors"
|
||||||
>
|
>
|
||||||
<CalendarIcon className="size-4" />
|
<CalendarIcon className="size-4" />
|
||||||
<span className="sr-only">Select date</span>
|
<span className="sr-only">Select date</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto overflow-hidden p-0 rounded-xl" align="end">
|
<PopoverContent
|
||||||
|
className="w-auto overflow-hidden rounded-xl p-0"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
<Calendar
|
<Calendar
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={date}
|
selected={date}
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({
|
function DialogTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogPortal({
|
function DialogPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({
|
function DialogClose({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogOverlay({
|
function DialogOverlay({
|
||||||
@@ -39,11 +39,11 @@ function DialogOverlay({
|
|||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogContent({
|
function DialogContent({
|
||||||
@@ -52,7 +52,7 @@ function DialogContent({
|
|||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
@@ -61,7 +61,7 @@ function DialogContent({
|
|||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -77,7 +77,7 @@ function DialogContent({
|
|||||||
)}
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -87,7 +87,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -96,11 +96,11 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({
|
function DialogTitle({
|
||||||
@@ -113,7 +113,7 @@ function DialogTitle({
|
|||||||
className={cn("text-lg leading-none font-semibold", className)}
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({
|
function DialogDescription({
|
||||||
@@ -126,7 +126,7 @@ function DialogDescription({
|
|||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -140,4 +140,4 @@ export {
|
|||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export function ImageWithSkeleton({
|
|||||||
"duration-700 ease-in-out",
|
"duration-700 ease-in-out",
|
||||||
isLoading
|
isLoading
|
||||||
? "scale-110 blur-2xl grayscale"
|
? "scale-110 blur-2xl grayscale"
|
||||||
: "scale-100 blur-0 grayscale-0",
|
: "blur-0 scale-100 grayscale-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
onLoad={() => setIsLoading(false)}
|
onLoad={() => setIsLoading(false)}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -130,11 +130,11 @@ function NavigationMenuLink({
|
|||||||
data-slot="navigation-menu-link"
|
data-slot="navigation-menu-link"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-foreground-foreground hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-foreground-foreground hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavigationMenuIndicator({
|
function NavigationMenuIndicator({
|
||||||
@@ -146,13 +146,13 @@ function NavigationMenuIndicator({
|
|||||||
data-slot="navigation-menu-indicator"
|
data-slot="navigation-menu-indicator"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||||
</NavigationMenuPrimitive.Indicator>
|
</NavigationMenuPrimitive.Indicator>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -165,4 +165,4 @@ export {
|
|||||||
NavigationMenuIndicator,
|
NavigationMenuIndicator,
|
||||||
NavigationMenuViewport,
|
NavigationMenuViewport,
|
||||||
navigationMenuTriggerStyle,
|
navigationMenuTriggerStyle,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
function Popover({
|
function Popover({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverTrigger({
|
function PopoverTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverContent({
|
function PopoverContent({
|
||||||
@@ -31,18 +31,18 @@ function PopoverContent({
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) border p-4 shadow-md outline-hidden",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) border p-4 shadow-md outline-hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</PopoverPrimitive.Portal>
|
</PopoverPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverAnchor({
|
function PopoverAnchor({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user