20 Commits

Author SHA1 Message Date
soconnor 0e46fdafb2 feat: add administration page and account role management
- Implemented `AdministrationContent` component for managing account roles.
- Created `AdministrationPage` to serve as the main entry point for administration tasks.
- Added PDF preview functionality with `PdfPreviewFrame` component for invoice generation.
- Introduced `InputColor` component for advanced color selection with various formats.
- Established color conversion utilities in `color-converter.ts` for handling color formats.
- Defined appearance-related schemas and types in `appearance.ts` for consistent theme management.
2026-04-30 10:50:50 -04:00
soconnor ddc2b42672 Refactor invoice data table and templates page for improved readability and functionality
- Cleaned up imports and formatted code for better readability in invoices-data-table.tsx.
- Enhanced invoice interface definitions for clarity.
- Improved toast messages for bulk delete and update actions.
- Refactored date formatting and status type retrieval for better readability.
- Simplified template management in templates page, extracting TemplateList component.
- Added registration toggle based on environment variable DISABLE_SIGNUPS.
- Updated navbar to conditionally render registration link based on allowRegistration prop.
- Enhanced error handling and validation in expenses and settings routers.
- Improved PDF export footer handling.
- Updated TRPC react integration for cleaner type imports.
2026-04-29 22:49:07 -04:00
soconnor dbb739b060 refactor: update SendEmailPage layout and remove SendEmailDialog component 2026-04-28 01:30:38 -04:00
soconnor bd3181fb9d feat: add PDF preview functionality and normalize email message handling 2026-04-28 01:26:47 -04:00
soconnor 915ec103fc feat: add email message field to invoices and update related components 2026-04-28 01:06:45 -04:00
soconnor 4108019eab feat: enhance PDF generation with improved line estimation and page budgeting 2026-04-28 00:44:00 -04:00
soconnor 84a5d997b4 refactor: remove InvoiceView component and update related email and invoice handling
- Deleted the InvoiceView component to streamline the codebase.
- Updated EmailPreview and SendEmailDialog components to include currency and notes fields.
- Enhanced invoice-form to handle default hourly rates and improved item mapping.
- Refactored email template generation to include notes and currency formatting.
- Adjusted API routers for invoices to calculate totals and handle notes and currency correctly.
2026-04-28 00:34:56 -04:00
soconnor ad89ad001d feat: update Dockerfile and docker-compose.yml to use WEB_PORT variable and streamline migration process 2026-04-27 22:49:13 -04:00
soconnor 4fd6772f2e refactor: streamline Dockerfile and docker-compose.yml for improved build process 2026-04-27 22:41:57 -04:00
soconnor fbeca7cfee feat: remove start.sh script and add appearance preferences management
- Deleted the start.sh script for container management.
- Added AGENTS.md for project guidelines and development principles.
- Introduced new SQL migration files for user appearance preferences and platform settings.
- Implemented appearance provider to manage user interface themes and preferences.
- Created branding utility to define and manage branding-related constants and types.

Co-authored-by: Copilot <copilot@github.com>
2026-04-27 22:24:43 -04:00
soconnor b582b6c88e update pdf generation to flow better 2026-04-27 14:15:06 -04:00
soconnor 00e066ca4e fix: register frutiger-bold as pdf font 2026-04-27 13:33:40 -04:00
soconnor 4214a4b4de add invoice prefixes, currency passing to pdf gen 2026-04-10 01:28:14 -04:00
soconnor af392e1bc9 remove reordering controls, add auto sort 2026-04-09 23:27:45 -04:00
Claude 74f9696023 Add tax features: summary report, deductible expenses, invoice tax fix, CSV export
- Add taxDeductible boolean to expenses schema + migration 0002
- Update expenses router, form, and list to support tax-deductible flag
- Fix invoice-view tax calculation (was hardcoded $0.00; now uses taxRate)
- New Tax Summary tab in Reports: year selector, income/deductions breakdown,
  SE tax + federal income estimates, quarterly bar chart
- CSV export for accountant with income + expense rows and tax summary

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:21:08 +00:00
Claude 1f76cf38a7 Fix migrate: remove bogus tracking entries from broken baseline
The previous baseline blindly recorded all migrations as applied.
Now on startup the script validates every recorded migration against
the actual schema; any entry whose schema changes don't exist is
deleted so migrate() will re-run that migration.

This unblocks the existing deployment where 0001 was recorded as done
but beenvoice_client.currency was never actually added.

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:11:43 +00:00
Claude e5242b37a4 Fix baseline: only mark migrations applied if schema changes already exist
Previously the baseline marked ALL migrations as done, causing 0001 to
be skipped even on databases that didn't have the currency column yet.

Now each migration is checked against a sentinel column/table before
being seeded into the tracking table. Migrations whose changes don't
exist yet are left out so migrate() runs them normally.

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:08:34 +00:00
Claude 38206f34fe Handle baseline migration for databases previously set up with db:push
When switching from db:push to db:migrate on an existing database,
the migration table is empty so Drizzle tries to re-run all migrations,
failing with "relation already exists".

Detect this case (tables exist but no migration history) and seed the
__drizzle_migrations tracking table with all current migrations so
Drizzle treats them as already applied. Future migrations run normally.

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:06:08 +00:00
Claude e950abd805 Fix migration files excluded from Docker build and restore fonts
- Remove drizzle/*.sql and drizzle/*-journal from .dockerignore so
  migration files are included in the Docker build context
- Restore next/font/google imports (removed prematurely due to local
  IP being 403'd by Google Fonts; production builds should work fine)
- Update CSS font fallbacks to use proper system font stacks

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:03:45 +00:00
Claude 4c0eae4b11 Fix build: resolve Turbopack client bundle and font issues
- Move EXPENSE_CATEGORIES to ~/lib/expense-categories.ts to break
  server router import chain from client component
- Use inline import() types in trpc/react.tsx to prevent Turbopack
  from including server modules (pg, db) in the client bundle
- Replace next/font/google with system font stacks to fix build
  failures in environments without Google Fonts access

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:00:25 +00:00
135 changed files with 10014 additions and 5321 deletions
+1 -3
View File
@@ -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
View File
@@ -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=""
+2 -1
View File
@@ -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
@@ -41,4 +42,4 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
# idea files # idea files
.idea .idea
View File
+26 -49
View File
@@ -1,59 +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 ./drizzle
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"]
+38 -4
View File
@@ -44,22 +44,26 @@ A modern, professional invoicing application built for freelancers and small bus
### Quick Start ### Quick Start
1. **Clone the repository** 1. **Clone the repository**
```bash ```bash
git clone https://github.com/yourusername/beenvoice.git git clone https://github.com/yourusername/beenvoice.git
cd beenvoice cd beenvoice
``` ```
2. **Install dependencies** 2. **Install dependencies**
```bash ```bash
bun install bun install
``` ```
3. **Set up environment variables** 3. **Set up environment variables**
```bash ```bash
cp .env.example .env.local cp .env.example .env.local
``` ```
Edit `.env.local` and add your configuration: Edit `.env.local` and add your configuration:
```env ```env
# Database # Database
DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice" DATABASE_URL="postgresql://postgres:password@localhost:5432/beenvoice"
@@ -78,17 +82,20 @@ A modern, professional invoicing application built for freelancers and small bus
RESEND_DOMAIN="yourdomain.com" RESEND_DOMAIN="yourdomain.com"
``` ```
4. **Start the database** 4. **Start the development database**
```bash ```bash
docker-compose up -d docker compose -f docker-compose.dev.yml up -d db
``` ```
5. **Push the database schema** 5. **Push the database schema**
```bash ```bash
bun run db:push bun run db:push
``` ```
6. **Start the development server** 6. **Start the development server**
```bash ```bash
bun run dev bun run dev
``` ```
@@ -123,7 +130,8 @@ beenvoice/
├── drizzle/ # Database migrations ├── drizzle/ # Database migrations
├── public/ # Static assets ├── public/ # Static assets
├── docs/ # Documentation ├── docs/ # Documentation
── docker-compose.yml # Local PostgreSQL setup ── docker-compose.yml # Deployment compose stack
└── docker-compose.dev.yml # Development overrides with exposed PostgreSQL
``` ```
## 🎯 Usage ## 🎯 Usage
@@ -155,12 +163,14 @@ beenvoice/
### Features Overview ### Features Overview
#### Client Management #### Client Management
- Create and edit client profiles - Create and edit client profiles
- Store contact information and addresses - Store contact information and addresses
- Set default hourly rates per client - Set default hourly rates per client
- Search and filter client list - Search and filter client list
#### Invoice Creation #### Invoice Creation
- Select from existing clients and business profiles - Select from existing clients and business profiles
- Add multiple line items with drag-and-drop reordering - Add multiple line items with drag-and-drop reordering
- Set custom rates per item - Set custom rates per item
@@ -169,12 +179,14 @@ beenvoice/
- Professional invoice formatting - Professional invoice formatting
#### Invoice Delivery #### Invoice Delivery
- Send invoices via email directly from the app - Send invoices via email directly from the app
- Rich text email composer with preview - Rich text email composer with preview
- Resend and re-deliver sent invoices - Resend and re-deliver sent invoices
- Track invoice status: Draft → Sent → Paid (+ Overdue) - Track invoice status: Draft → Sent → Paid (+ Overdue)
#### User Interface #### User Interface
- Clean, modern design - Clean, modern design
- Fully responsive — desktop, tablet, and mobile - Fully responsive — desktop, tablet, and mobile
- Intuitive navigation with breadcrumbs - Intuitive navigation with breadcrumbs
@@ -198,7 +210,8 @@ bun run db:studio # Open Drizzle Studio
bun run db:generate # Generate new migration bun run db:generate # Generate new migration
# Docker # Docker
bun run docker:up # Start local PostgreSQL via Docker bun run docker:up # Start deployment compose stack
bun run docker:dev:up # Start development compose stack with exposed PostgreSQL
bun run docker:down # Stop Docker services bun run docker:down # Stop Docker services
# Code Quality # Code Quality
@@ -208,6 +221,24 @@ bun run format:write # Format code with Prettier
bun run typecheck # Run TypeScript type checking bun run typecheck # Run TypeScript type checking
``` ```
### Docker Compose
Use the base compose file for deployment. It keeps PostgreSQL internal to the
compose network:
```bash
docker compose up -d
```
For local development, use the dev compose file to expose PostgreSQL on
`${POSTGRES_PORT:-5432}`:
```bash
docker compose -f docker-compose.dev.yml up -d
```
Set `DISABLE_SIGNUPS=true` to block new email/password account registration.
### Database Schema ### Database Schema
The application uses the following core tables: The application uses the following core tables:
@@ -243,6 +274,7 @@ The app uses Tailwind CSS v4 with a custom design system:
### Branding ### Branding
Update the logo and colors in: Update the logo and colors in:
- `src/components/logo.tsx` - Main logo component - `src/components/logo.tsx` - Main logo component
- `src/styles/globals.css` - Color variables - `src/styles/globals.css` - Color variables
- `src/app/layout.tsx` - Font configuration - `src/app/layout.tsx` - Font configuration
@@ -252,6 +284,7 @@ Update the logo and colors in:
You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.). You can deploy this application to any platform that supports Next.js and PostgreSQL (Docker, Coolify, Railway, etc.).
1. **Build the application:** 1. **Build the application:**
```bash ```bash
bun run build bun run build
``` ```
@@ -259,6 +292,7 @@ You can deploy this application to any platform that supports Next.js and Postgr
2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production) 2. **Set up production environment variables** (see `.env.local` example above, adjusting URLs and secrets for production)
3. **Run database migrations:** 3. **Run database migrations:**
```bash ```bash
bun run db:push bun run db:push
``` ```
+312 -309
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes:
- beenvoice_dev_pg_data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
interval: 5s
timeout: 5s
retries: 10
ports:
- "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped
volumes:
beenvoice_dev_pg_data:
+35 -9
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "beenvoice_expense" ADD COLUMN "taxDeductible" boolean DEFAULT false NOT NULL;
+2
View File
@@ -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;
+14
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "beenvoice_invoice"
ADD COLUMN "emailMessage" varchar(2000);
+43 -1
View File
@@ -15,6 +15,48 @@
"when": 1775356013998, "when": 1775356013998,
"tag": "0001_supreme_the_enforcers", "tag": "0001_supreme_the_enforcers",
"breakpoints": true "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
View File
@@ -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
View File
@@ -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": "bun src/server/db/migrate.ts", "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.2.2", "next": "^16.2.4",
"pg": "8.13.1", "pg": "8.13.1",
"react": "^19.2.4", "react": "^19.2.5",
"react-colorful": "^5.6.1",
"react-day-picker": "^9.12.0", "react-day-picker": "^9.12.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.5",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"recharts": "^3.5.1", "recharts": "^3.5.1",
"resend": "^4.8.0", "resend": "^4.8.0",
@@ -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",
+4 -3
View File
@@ -35,9 +35,10 @@ export default function TermsOfServicePage() {
</CardHeader> </CardHeader>
<CardContent className="prose prose-sm max-w-none"> <CardContent className="prose prose-sm max-w-none">
<p> <p>
These Terms of Service (&quot;Terms&quot;) govern your use of the These Terms of Service (&quot;Terms&quot;) govern your use of
beenvoice platform and services (the &quot;Service&quot;) operated by the beenvoice platform and services (the &quot;Service&quot;)
beenvoice (&quot;us&quot;, &quot;we&quot;, or &quot;our&quot;). operated by beenvoice (&quot;us&quot;, &quot;we&quot;, or
&quot;our&quot;).
</p> </p>
<p> <p>
By accessing or using our Service, you agree to be bound by By accessing or using our Service, you agree to be bound by
+14
View File
@@ -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);
+55 -17
View File
@@ -2,59 +2,97 @@ import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { env } from "~/env";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
const registerSchema = z.object({ const registerSchema = z.object({
firstName: z.string().min(1, "First name is required"), firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"), lastName: z.string().trim().min(1, "Last name is required"),
email: z.string().email("Invalid email address"), email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"), password: z.string().min(8, "Password must be at least 8 characters"),
}); });
const fieldLabels: Record<string, string> = {
firstName: "First name",
lastName: "Last name",
email: "Email address",
password: "Password",
};
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() as z.infer<typeof registerSchema>; if (env.DISABLE_SIGNUPS === true) {
return NextResponse.json(
{ error: "New account registration is currently disabled" },
{ status: 403 },
);
}
const body = (await request.json()) as unknown;
const { firstName, lastName, email, password } = registerSchema.parse(body); const { firstName, lastName, email, password } = registerSchema.parse(body);
const normalizedEmail = email.toLowerCase();
// Check if user already exists // Check if user already exists
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.email, email), where: eq(users.email, normalizedEmail),
}); });
if (existingUser) { if (existingUser) {
return NextResponse.json( return NextResponse.json(
{ error: "User with this email already exists" }, { error: "User with this email already exists" },
{ status: 400 } { status: 400 },
); );
} }
// Hash password // Hash password
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
// Create user await db.transaction(async (tx) => {
await db.insert(users).values({ const [user] = await tx
name: `${firstName} ${lastName}`, .insert(users)
email, .values({
password: hashedPassword, name: `${firstName} ${lastName}`,
email: normalizedEmail,
password: hashedPassword,
})
.returning({ id: users.id });
if (!user) {
throw new Error("Failed to create user");
}
await tx.insert(accounts).values({
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
});
}); });
return NextResponse.json( return NextResponse.json(
{ message: "User created successfully" }, { message: "User created successfully" },
{ status: 201 } { status: 201 },
); );
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
const issue = error.errors[0];
const field = issue?.path[0];
const fallback =
typeof field === "string"
? `${fieldLabels[field] ?? field} is required`
: "Please check the registration form";
return NextResponse.json( return NextResponse.json(
{ error: error.errors[0]?.message ?? "Validation error" }, { error: issue?.message === "Required" ? fallback : issue?.message },
{ status: 400 } { status: 400 },
); );
} }
console.error("Registration error:", error); console.error("Registration error:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },
{ status: 500 } { status: 500 },
); );
} }
} }
+35 -10
View File
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server";
import { eq, and, gt } from "drizzle-orm"; import { eq, and, gt } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -47,15 +47,40 @@ export async function POST(request: NextRequest) {
// Hash the new password // Hash the new password
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
// Update user with new password and clear reset token await db.transaction(async (tx) => {
await db await tx
.update(users) .update(users)
.set({ .set({
password: hashedPassword, password: hashedPassword,
resetToken: null, resetToken: null,
resetTokenExpiry: null, resetTokenExpiry: null,
}) })
.where(eq(users.id, user.id)); .where(eq(users.id, user.id));
const credentialAccount = await tx.query.accounts.findFirst({
where: and(
eq(accounts.userId, user.id),
eq(accounts.providerId, "credential"),
),
});
if (credentialAccount) {
await tx
.update(accounts)
.set({
password: hashedPassword,
updatedAt: new Date(),
})
.where(eq(accounts.id, credentialAccount.id));
} else {
await tx.insert(accounts).values({
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
});
}
});
return NextResponse.json( return NextResponse.json(
{ {
+2 -1
View File
@@ -28,7 +28,8 @@ function RegisterForm() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
name: `${firstName} ${lastName}`, firstName,
lastName,
email, email,
password, password,
}), }),
+3 -2
View File
@@ -29,11 +29,12 @@ function ResetPasswordForm() {
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [tokenValid, setTokenValid] = useState<boolean | null>(null); const [tokenValid, setTokenValid] = useState<boolean | null>(() =>
token ? null : false,
);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
setTokenValid(false);
return; return;
} }
+4 -270
View File
@@ -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&apos;t have an account?{" "}
<a
href="/auth/register"
className="text-primary font-medium hover:underline"
>
Sign up
</a>
</div>
<div className="text-muted-foreground text-center text-xs leading-relaxed">
By signing in, you agree to our{" "}
<LegalModal
type="terms"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Terms of Service
</span>
}
/>{" "}
and{" "}
<LegalModal
type="privacy"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Privacy Policy
</span>
}
/>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function SignInPage() { export default function SignInPage() {
return ( return (
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
<SignInForm /> <SignInForm allowRegistration={env.DISABLE_SIGNUPS !== true} />
</Suspense> </Suspense>
); );
} }
+303
View File
@@ -0,0 +1,303 @@
"use client";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { authClient } from "~/lib/auth-client";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalModal } from "~/components/ui/legal-modal";
import { env } from "~/env";
import {
Mail,
Lock,
ArrowRight,
Users,
FileText,
TrendingUp,
Shield,
} from "lucide-react";
interface SignInFormProps {
allowRegistration: boolean;
}
export function SignInForm({ allowRegistration }: SignInFormProps) {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const signupDisabled = searchParams.get("signup") === "disabled";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const { error } = await authClient.signIn.email({
email,
password,
});
setLoading(false);
if (error) {
toast.error(error.message ?? "Invalid email or password");
} else {
toast.success("Signed in successfully!");
router.push(callbackUrl);
router.refresh();
}
}
async function handleSocialSignIn() {
setLoading(true);
try {
await authClient.signIn.oauth2({
providerId: "authentik",
callbackURL: callbackUrl,
});
// The signIn.sso method will automatically redirect to the SSO provider
} catch (error) {
console.error("[SSO Error]", error);
setLoading(false);
}
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
{/* Blob Background */}
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div>
<Card className="md:bg-background/80 md:border-border/50 mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:rounded-3xl md:border md:shadow-2xl md:backdrop-blur-xl">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-primary/5 border-border/50 relative hidden border-r md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="font-heading text-3xl font-bold lg:text-4xl">
Welcome back to your
<span className="text-primary italic">
{" "}
invoicing workspace
</span>
</h1>
<p className="text-muted-foreground text-lg">
Continue managing your clients and creating professional
invoices that get you paid faster.
</p>
</div>
</div>
<div className="grid gap-6">
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<Users className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Client Management
</h3>
<p className="text-muted-foreground text-sm">
Organize and track all your clients in one place
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<FileText className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Professional Invoices
</h3>
<p className="text-muted-foreground text-sm">
Beautiful templates that get you paid faster
</p>
</div>
</div>
<div className="flex items-start space-x-4">
<div className="bg-primary/10 rounded-xl p-3">
<TrendingUp className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-foreground font-semibold">
Payment Tracking
</h3>
<p className="text-muted-foreground text-sm">
Monitor your income with real-time insights
</p>
</div>
</div>
</div>
</div>
</div>
{/* Sign In Form */}
<div className="flex flex-col justify-center p-6 md:p-12">
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Mobile Logo */}
<div className="flex justify-center md:hidden">
<Logo size="lg" />
</div>
<div className="space-y-2 text-center md:text-left">
<h1 className="font-heading text-3xl font-bold">Sign In</h1>
<p className="text-muted-foreground">
Enter your credentials to access your account
</p>
</div>
{signupDisabled && (
<div className="border-border bg-muted/50 text-muted-foreground rounded-lg border px-3 py-2 text-sm">
New account registration is currently disabled.
</div>
)}
{authentikEnabled && (
<div className="space-y-4">
<Button
variant="outline"
type="button"
className="relative h-11 w-full rounded-xl"
onClick={handleSocialSignIn}
disabled={loading}
>
<Shield className="mr-2 h-4 w-4" />
Sign in with Authentik
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="border-border/50 w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
Or continue with
</span>
</div>
</div>
</div>
)}
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="m@example.com"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<a
href="/auth/forgot-password"
className="text-primary text-sm hover:underline"
>
Forgot password?
</a>
</div>
<div className="relative">
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="Enter your password"
/>
</div>
</div>
<Button
type="submit"
className="shadow-primary/20 hover:shadow-primary/30 h-11 w-full rounded-xl text-base shadow-lg"
disabled={loading}
>
{loading ? (
<div className="flex items-center space-x-2">
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2"></div>
<span>Signing in...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Sign In</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
{allowRegistration && (
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a
href="/auth/register"
className="text-primary font-medium hover:underline"
>
Sign up
</a>
</div>
)}
<div className="text-muted-foreground text-center text-xs leading-relaxed">
By signing in, you agree to our{" "}
<LegalModal
type="terms"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Terms of Service
</span>
}
/>{" "}
and{" "}
<LegalModal
type="privacy"
trigger={
<span className="text-primary inline cursor-pointer hover:underline">
Privacy Policy
</span>
}
/>
.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default function SignInPageClient() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SignInForm allowRegistration />
</Suspense>
);
}
@@ -16,6 +16,47 @@ interface InvoiceStatusChartProps {
invoices: Invoice[]; invoices: Invoice[];
} }
const STATUS_COLORS = {
draft: "hsl(0, 0%, 60%)",
sent: "hsl(217, 91%, 60%)",
pending: "hsl(217, 91%, 60%)",
paid: "hsl(142, 71%, 45%)",
overdue: "hsl(var(--destructive))",
} as const;
const formatChartCurrency = (value: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
function StatusTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{
payload: { name: string; count: number; value: number };
}>;
}) {
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{data.name}</p>
<p className="text-sm">
{data.count} invoice{data.count !== 1 ? "s" : ""}
</p>
<p className="text-sm">{formatChartCurrency(data.value)}</p>
</div>
);
}
return null;
}
export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) { export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
// Process invoice data to create status breakdown // Process invoice data to create status breakdown
const statusData = invoices.reduce( const statusData = invoices.reduce(
@@ -44,14 +85,6 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
name: item.status.charAt(0).toUpperCase() + item.status.slice(1), name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
})); }));
// Use theme-aware colors
const COLORS = {
draft: "hsl(0, 0%, 60%)", // neutral grey - matches monthly metrics chart
sent: "hsl(217, 91%, 60%)", // vibrant blue
pending: "hsl(217, 91%, 60%)", // blue
paid: "hsl(142, 71%, 45%)", // vibrant green
overdue: "hsl(var(--destructive))", // red
};
// Animation / motion preferences // Animation / motion preferences
const { prefersReducedMotion, animationSpeedMultiplier } = const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences(); useAnimationPreferences();
@@ -59,39 +92,6 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
600 / (animationSpeedMultiplier || 1), 600 / (animationSpeedMultiplier || 1),
); );
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const CustomTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: Array<{
payload: { name: string; count: number; value: number };
}>;
}) => {
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{data.name}</p>
<p className="text-sm">
{data.count} invoice{data.count !== 1 ? "s" : ""}
</p>
<p className="text-sm">{formatCurrency(data.value)}</p>
</div>
);
}
return null;
};
if (chartData.length === 0) { if (chartData.length === 0) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
@@ -127,11 +127,13 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
{chartData.map((entry, index) => ( {chartData.map((entry, index) => (
<Cell <Cell
key={`cell-${index}`} key={`cell-${index}`}
fill={COLORS[entry.status as keyof typeof COLORS]} fill={
STATUS_COLORS[entry.status as keyof typeof STATUS_COLORS]
}
/> />
))} ))}
</Pie> </Pie>
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<StatusTooltip />} />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -144,7 +146,8 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
<div <div
className="h-3 w-3 rounded-full" className="h-3 w-3 rounded-full"
style={{ style={{
backgroundColor: COLORS[item.status as keyof typeof COLORS], backgroundColor:
STATUS_COLORS[item.status as keyof typeof STATUS_COLORS],
}} }}
/> />
<span className="text-sm font-medium">{item.name}</span> <span className="text-sm font-medium">{item.name}</span>
@@ -152,7 +155,7 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
<div className="text-right"> <div className="text-right">
<p className="text-sm font-medium">{item.count}</p> <p className="text-sm font-medium">{item.count}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{formatCurrency(item.value)} {formatChartCurrency(item.value)}
</p> </p>
</div> </div>
</div> </div>
@@ -24,6 +24,43 @@ interface MonthlyMetricsChartProps {
invoices: Invoice[]; invoices: Invoice[];
} }
function MonthlyMetricsTooltip({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
payload: {
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
totalInvoices: number;
};
}>;
label?: string;
}) {
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p>
<div className="space-y-1 text-sm">
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
<p className="text-primary/80">Pending: {data.pendingInvoices}</p>
<p className="text-destructive">Overdue: {data.overdueInvoices}</p>
<p className="text-muted-foreground">Draft: {data.draftInvoices}</p>
<p className="text-foreground border-t pt-1 font-medium">
Total: {data.totalInvoices}
</p>
</div>
</div>
);
}
return null;
}
export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) { export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
// Process invoice data to create monthly metrics // Process invoice data to create monthly metrics
const monthlyData = invoices.reduce( const monthlyData = invoices.reduce(
@@ -95,49 +132,6 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
500 / (animationSpeedMultiplier || 1), 500 / (animationSpeedMultiplier || 1),
); );
const CustomTooltip = ({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
payload: {
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
totalInvoices: number;
};
}>;
label?: string;
}) => {
if (active && payload?.length) {
const data = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p>
<div className="space-y-1 text-sm">
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
<p className="text-primary/80">
Pending: {data.pendingInvoices}
</p>
<p className="text-destructive">
Overdue: {data.overdueInvoices}
</p>
<p className="text-muted-foreground">
Draft: {data.draftInvoices}
</p>
<p className="text-foreground font-medium border-t pt-1">
Total: {data.totalInvoices}
</p>
</div>
</div>
);
}
return null;
};
if (chartData.length === 0) { if (chartData.length === 0) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
@@ -169,7 +163,7 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
tickLine={false} tickLine={false}
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }} tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
/> />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<MonthlyMetricsTooltip />} />
<Bar <Bar
dataKey="draftInvoices" dataKey="draftInvoices"
stackId="a" stackId="a"
@@ -235,9 +229,7 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
<span className="text-xs">Pending</span> <span className="text-xs">Pending</span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div className="bg-destructive h-3 w-3 rounded-full" />
className="h-3 w-3 rounded-full bg-destructive"
/>
<span className="text-xs">Overdue</span> <span className="text-xs">Overdue</span>
</div> </div>
</div> </div>
@@ -10,8 +10,6 @@ import {
} from "recharts"; } from "recharts";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface RevenueChartProps { interface RevenueChartProps {
data: { data: {
month: string; month: string;
@@ -91,7 +89,11 @@ export function RevenueChart({ data }: RevenueChartProps) {
<AreaChart data={chartData}> <AreaChart data={chartData}>
<defs> <defs>
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0.4} /> <stop
offset="5%"
stopColor="hsl(217, 91%, 60%)"
stopOpacity={0.4}
/>
<stop <stop
offset="95%" offset="95%"
stopColor="hsl(217, 91%, 60%)" stopColor="hsl(217, 91%, 60%)"
@@ -229,7 +229,7 @@ export function StatusManager({
{/* Overdue Warning */} {/* Overdue Warning */}
{isOverdue && ( {isOverdue && (
<div className="bg-destructive/10 text-destructive flex items-center gap-2 p-3"> <div className="bg-destructive/10 text-destructive flex items-center gap-2 p-3">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{daysPastDue} day{daysPastDue !== 1 ? "s" : ""} overdue {daysPastDue} day{daysPastDue !== 1 ? "s" : ""} overdue
@@ -325,7 +325,7 @@ export function StatusManager({
{/* No Email Warning */} {/* No Email Warning */}
{!clientEmail && effectiveStatus !== "paid" && ( {!clientEmail && effectiveStatus !== "paid" && (
<div className="bg-muted text-muted-foreground p-3"> <div className="bg-muted text-muted-foreground p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
@@ -0,0 +1,101 @@
"use client";
import { Shield } from "lucide-react";
import { toast } from "sonner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { api } from "~/trpc/react";
export function AdministrationContent() {
const {
data: accounts = [],
refetch,
error,
} = api.settings.listAccounts.useQuery();
const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({
onSuccess: () => {
toast.success("Account role updated");
void refetch();
},
onError: (mutationError: { message: string }) => {
toast.error(`Failed to update role: ${mutationError.message}`);
},
});
if (error) {
return (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
Administration
</CardTitle>
<CardDescription>
Administrative access is required for this page.
</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
Accounts
</CardTitle>
<CardDescription>
Manage account access and roles without opening customer data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{accounts.map((account) => (
<div
key={account.id}
className="border-border flex flex-col gap-3 border p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="text-sm font-medium">{account.name}</p>
<p className="text-muted-foreground truncate text-xs">
{account.email}
</p>
<p className="text-muted-foreground mt-1 text-xs">
Created {new Date(account.createdAt).toLocaleDateString()}
</p>
</div>
<Select
value={account.role}
onValueChange={(role) =>
updateAccountRoleMutation.mutate({
userId: account.id,
role: role as "user" | "admin",
})
}
>
<SelectTrigger className="w-full sm:w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
))}
</CardContent>
</Card>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { DataTableSkeleton } from "~/components/data/data-table";
import { PageHeader } from "~/components/layout/page-header";
import { HydrateClient } from "~/trpc/server";
import { AdministrationContent } from "./_components/administration-content";
export default async function AdministrationPage() {
return (
<div className="page-enter space-y-6">
<PageHeader
title="Administration"
description="Manage account access and platform administration"
variant="gradient"
/>
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
<AdministrationContent />
</Suspense>
</HydrateClient>
</div>
);
}
+259 -51
View File
@@ -29,7 +29,7 @@ import { NumberInput } from "~/components/ui/number-input";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Pencil, Trash2, Receipt } from "lucide-react"; import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency"; import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
import { EXPENSE_CATEGORIES } from "~/server/api/routers/expenses"; import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
interface ExpenseFormData { interface ExpenseFormData {
date: Date; date: Date;
@@ -39,6 +39,7 @@ interface ExpenseFormData {
category: string; category: string;
billable: boolean; billable: boolean;
reimbursable: boolean; reimbursable: boolean;
taxDeductible: boolean;
notes: string; notes: string;
clientId: string; clientId: string;
} }
@@ -51,6 +52,7 @@ const defaultForm: ExpenseFormData = {
category: "", category: "",
billable: false, billable: false,
reimbursable: false, reimbursable: false,
taxDeductible: false,
notes: "", notes: "",
clientId: "", clientId: "",
}; };
@@ -66,20 +68,39 @@ export default function ExpensesPage() {
const { data: clients = [] } = api.clients.getAll.useQuery(); const { data: clients = [] } = api.clients.getAll.useQuery();
const create = api.expenses.create.useMutation({ const create = api.expenses.create.useMutation({
onSuccess: () => { toast.success("Expense added"); void utils.expenses.getAll.invalidate(); setOpen(false); setForm(defaultForm); }, onSuccess: () => {
toast.success("Expense added");
void utils.expenses.getAll.invalidate();
setOpen(false);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const update = api.expenses.update.useMutation({ const update = api.expenses.update.useMutation({
onSuccess: () => { toast.success("Expense updated"); void utils.expenses.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); }, onSuccess: () => {
toast.success("Expense updated");
void utils.expenses.getAll.invalidate();
setOpen(false);
setEditId(null);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const del = api.expenses.delete.useMutation({ const del = api.expenses.delete.useMutation({
onSuccess: () => { toast.success("Expense deleted"); void utils.expenses.getAll.invalidate(); setDeleteId(null); }, onSuccess: () => {
toast.success("Expense deleted");
void utils.expenses.getAll.invalidate();
setDeleteId(null);
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
const handleOpen = () => { setEditId(null); setForm(defaultForm); setOpen(true); }; const handleOpen = () => {
const handleEdit = (expense: typeof expenses[0]) => { setEditId(null);
setForm(defaultForm);
setOpen(true);
};
const handleEdit = (expense: (typeof expenses)[0]) => {
setEditId(expense.id); setEditId(expense.id);
setForm({ setForm({
date: new Date(expense.date), date: new Date(expense.date),
@@ -89,47 +110,93 @@ export default function ExpensesPage() {
category: expense.category ?? "", category: expense.category ?? "",
billable: expense.billable, billable: expense.billable,
reimbursable: expense.reimbursable, reimbursable: expense.reimbursable,
taxDeductible: expense.taxDeductible ?? false,
notes: expense.notes ?? "", notes: expense.notes ?? "",
clientId: expense.clientId ?? "", clientId: expense.clientId ?? "",
}); });
setOpen(true); setOpen(true);
}; };
const handleSubmit = () => { const handleSubmit = () => {
if (!form.description.trim()) { toast.error("Description is required"); return; } if (!form.description.trim()) {
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; } toast.error("Description is required");
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined }; return;
}
if (form.amount <= 0) {
toast.error("Amount must be greater than 0");
return;
}
const payload = {
...form,
clientId: form.clientId || undefined,
category: form.category || undefined,
notes: form.notes || undefined,
taxDeductible: form.taxDeductible,
};
if (editId) update.mutate({ id: editId, ...payload }); if (editId) update.mutate({ id: editId, ...payload });
else create.mutate(payload); else create.mutate(payload);
}; };
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0); const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0); const billableTotal = expenses
.filter((e) => e.billable)
.reduce((s, e) => s + e.amount, 0);
const deductibleTotal = expenses
.filter((e) => e.taxDeductible)
.reduce((s, e) => s + e.amount, 0);
return ( return (
<div className="page-enter space-y-6 pb-6"> <div className="page-enter space-y-6 pb-6">
<PageHeader title="Expenses" description="Track billable and non-billable expenses" variant="gradient"> <PageHeader
<Button onClick={handleOpen} variant="default" className="hover-lift shadow-md"> title="Expenses"
description="Track billable and non-billable expenses"
variant="gradient"
>
<Button
onClick={handleOpen}
variant="default"
className="hover-lift shadow-md"
>
<Plus className="mr-2 h-5 w-5" /> Add Expense <Plus className="mr-2 h-5 w-5" /> Add Expense
</Button> </Button>
</PageHeader> </PageHeader>
{/* Summary cards */} {/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p> <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
<p className="mt-1 text-2xl font-bold">{formatCurrency(totalExpenses)}</p> Total
</p>
<p className="mt-1 text-2xl font-bold">
{formatCurrency(totalExpenses)}
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Billable</p> <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p> Billable
</p>
<p className="text-primary mt-1 text-2xl font-bold">
{formatCurrency(billableTotal)}
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="col-span-2 sm:col-span-1"> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p> <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
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> <p className="mt-1 text-2xl font-bold">{expenses.length}</p>
</CardContent> </CardContent>
</Card> </Card>
@@ -144,33 +211,84 @@ export default function ExpensesPage() {
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{isLoading ? ( {isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">Loading</div> <div className="text-muted-foreground p-6 text-center text-sm">
Loading
</div>
) : expenses.length === 0 ? ( ) : expenses.length === 0 ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" /> <Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground text-sm">No expenses yet. Add your first expense.</p> <p className="text-muted-foreground text-sm">
No expenses yet. Add your first expense.
</p>
</div> </div>
) : ( ) : (
<div className="divide-y"> <div className="divide-y">
{expenses.map((expense) => ( {expenses.map((expense) => (
<div key={expense.id} className="flex items-start justify-between gap-3 p-4"> <div
key={expense.id}
className="flex items-start justify-between gap-3 p-4"
>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{expense.description}</p> <p className="font-medium">{expense.description}</p>
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>} {expense.billable && (
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>} <Badge variant="secondary" className="text-xs">
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>} 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> </div>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
{new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(expense.date))} {new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(expense.date))}
{expense.client ? ` · ${expense.client.name}` : ""} {expense.client ? ` · ${expense.client.name}` : ""}
</p> </p>
{expense.notes && <p className="text-muted-foreground mt-1 text-xs">{expense.notes}</p>} {expense.notes && (
<p className="text-muted-foreground mt-1 text-xs">
{expense.notes}
</p>
)}
</div> </div>
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 items-center gap-2">
<p className="font-semibold">{formatCurrency(expense.amount, expense.currency)}</p> <p className="font-semibold">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(expense)}><Pencil className="h-3.5 w-3.5" /></Button> {formatCurrency(expense.amount, expense.currency)}
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(expense.id)}><Trash2 className="h-3.5 w-3.5" /></Button> </p>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleEdit(expense)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive h-8 w-8 p-0"
onClick={() => setDeleteId(expense.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div> </div>
</div> </div>
))} ))}
@@ -188,66 +306,150 @@ export default function ExpensesPage() {
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>Description *</Label> <Label>Description *</Label>
<Input value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} placeholder="e.g. Laptop charger" /> <Input
value={form.description}
onChange={(e) =>
setForm((p) => ({ ...p, description: e.target.value }))
}
placeholder="e.g. Laptop charger"
/>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-2"> <div className="space-y-2">
<Label>Amount *</Label> <Label>Amount *</Label>
<NumberInput value={form.amount} onChange={(v) => setForm((p) => ({ ...p, amount: v }))} min={0} step={0.01} /> <NumberInput
value={form.amount}
onChange={(v) => setForm((p) => ({ ...p, amount: v }))}
min={0}
step={0.01}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Currency</Label> <Label>Currency</Label>
<Select value={form.currency} onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}> <Select
<SelectTrigger><SelectValue /></SelectTrigger> value={form.currency}
<SelectContent>{SUPPORTED_CURRENCIES.map((c) => <SelectItem key={c.code} value={c.code}>{c.code}</SelectItem>)}</SelectContent> onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_CURRENCIES.map((c) => (
<SelectItem key={c.code} value={c.code}>
{c.code}
</SelectItem>
))}
</SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-2"> <div className="space-y-2">
<Label>Date</Label> <Label>Date</Label>
<DatePicker date={form.date} onDateChange={(d) => setForm((p) => ({ ...p, date: d ?? new Date() }))} className="w-full" /> <DatePicker
date={form.date}
onDateChange={(d) =>
setForm((p) => ({ ...p, date: d ?? new Date() }))
}
className="w-full"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Category</Label> <Label>Category</Label>
<Select value={form.category || "none"} onValueChange={(v) => setForm((p) => ({ ...p, category: v === "none" ? "" : v }))}> <Select
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger> value={form.category || "none"}
onValueChange={(v) =>
setForm((p) => ({ ...p, category: v === "none" ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
{EXPENSE_CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)} {EXPENSE_CATEGORIES.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Client (optional)</Label> <Label>Client (optional)</Label>
<Select value={form.clientId || "none"} onValueChange={(v) => setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))}> <Select
<SelectTrigger><SelectValue placeholder="No client" /></SelectTrigger> value={form.clientId || "none"}
onValueChange={(v) =>
setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder="No client" />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">No client</SelectItem> <SelectItem value="none">No client</SelectItem>
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)} {clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex gap-6"> <div className="flex flex-wrap gap-6">
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} /> <Checkbox
checked={form.billable}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, billable: !!v }))
}
/>
<span className="text-sm">Billable</span> <span className="text-sm">Billable</span>
</label> </label>
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} /> <Checkbox
checked={form.reimbursable}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, reimbursable: !!v }))
}
/>
<span className="text-sm">Reimbursable</span> <span className="text-sm">Reimbursable</span>
</label> </label>
<label className="flex cursor-pointer items-center gap-2">
<Checkbox
checked={form.taxDeductible}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, taxDeductible: !!v }))
}
/>
<span className="text-sm">Tax Deductible</span>
</label>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Notes (optional)</Label> <Label>Notes (optional)</Label>
<Input value={form.notes} onChange={(e) => setForm((p) => ({ ...p, notes: e.target.value }))} placeholder="Additional details…" /> <Input
value={form.notes}
onChange={(e) =>
setForm((p) => ({ ...p, notes: e.target.value }))
}
placeholder="Additional details…"
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="outline" onClick={() => setOpen(false)}>
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}> Cancel
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Add Expense"} </Button>
<Button
onClick={handleSubmit}
disabled={create.isPending || update.isPending}
>
{create.isPending || update.isPending
? "Saving…"
: editId
? "Update"
: "Add Expense"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -261,8 +463,14 @@ export default function ExpensesPage() {
<DialogDescription>This action cannot be undone.</DialogDescription> <DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button> <Button variant="outline" onClick={() => setDeleteId(null)}>
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}> Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && del.mutate({ id: deleteId })}
disabled={del.isPending}
>
{del.isPending ? "Deleting…" : "Delete"} {del.isPending ? "Deleting…" : "Delete"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -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" />
@@ -53,14 +53,13 @@ const columns: ColumnDef<InvoiceItem>[] = [
return ( return (
<> <>
{/* Desktop: plain description */} {/* Desktop: plain description */}
<div className="hidden font-medium sm:block"> <div className="hidden font-medium sm:block">{item.description}</div>
{item.description}
</div>
{/* Mobile: description + date + hours @ rate stacked */} {/* Mobile: description + date + hours @ rate stacked */}
<div className="sm:hidden"> <div className="sm:hidden">
<p className="font-medium">{item.description}</p> <p className="font-medium">{item.description}</p>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
{formatDate(item.date)} &middot; {item.hours}h @ {formatCurrency(item.rate)}/hr {formatDate(item.date)} &middot; {item.hours}h @{" "}
{formatCurrency(item.rate)}/hr
</p> </p>
</div> </div>
</> </>
@@ -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>
);
}
+8 -10
View File
@@ -75,7 +75,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const handleMarkAsPaid = () => { const handleMarkAsPaid = () => {
updateStatus.mutate({ updateStatus.mutate({
id: invoiceId, id: invoiceId,
status: "paid" as StoredInvoiceStatus, status: "paid",
}); });
}; };
@@ -99,27 +99,25 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
}).format(new Date(date)); }).format(new Date(date));
}; };
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number, currency = invoice.currency) => {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency,
}).format(amount); }).format(amount);
}; };
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0); const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = (subtotal * invoice.taxRate) / 100; const taxAmount = (subtotal * invoice.taxRate) / 100;
const total = subtotal + taxAmount; const total = subtotal + taxAmount;
const storedStatus = invoice.status as StoredInvoiceStatus;
const effectiveStatus = getEffectiveInvoiceStatus( const effectiveStatus = getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus, storedStatus,
invoice.dueDate,
);
const isOverdue = isInvoiceOverdue(
invoice.status as StoredInvoiceStatus,
invoice.dueDate, invoice.dueDate,
); );
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate);
const getStatusType = (): StatusType => { const getStatusType = (): StatusType => {
return effectiveStatus as StatusType; return effectiveStatus;
}; };
return ( return (
@@ -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" />
+97 -43
View File
@@ -54,6 +54,32 @@ function SendEmailPageSkeleton() {
); );
} }
function plainTextToHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\n/g, "<br>");
}
function normalizeEmailNoteHtml(value: string) {
const visibleText = value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;|\u00a0/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
return visibleText ? value.trim() : "";
}
export default function SendEmailPage() { export default function SendEmailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
@@ -136,9 +162,9 @@ export default function SendEmailPage() {
action: action:
canRetry && retryCount < 2 canRetry && retryCount < 2
? { ? {
label: "Retry", label: "Retry",
onClick: () => handleRetry(), onClick: () => handleRetry(),
} }
: undefined, : undefined,
}); });
@@ -150,34 +176,45 @@ export default function SendEmailPage() {
const invoice = useMemo(() => { const invoice = useMemo(() => {
return invoiceData return invoiceData
? { ? {
id: invoiceData.id, id: invoiceData.id,
invoiceNumber: invoiceData.invoiceNumber, invoiceNumber: invoiceData.invoiceNumber,
issueDate: invoiceData.issueDate, issueDate: invoiceData.issueDate,
dueDate: invoiceData.dueDate, dueDate: invoiceData.dueDate,
status: invoiceData.status, status: invoiceData.status,
taxRate: invoiceData.taxRate, totalAmount: invoiceData.totalAmount,
client: invoiceData.client taxRate: invoiceData.taxRate,
? { currency: invoiceData.currency,
name: invoiceData.client.name, emailMessage: invoiceData.emailMessage,
email: invoiceData.client.email, client: invoiceData.client
} ? {
: undefined, name: invoiceData.client.name,
business: invoiceData.business email: invoiceData.client.email,
? { }
name: invoiceData.business.name, : undefined,
nickname: invoiceData.business.nickname, business: invoiceData.business
email: invoiceData.business.email, ? {
} name: invoiceData.business.name,
: undefined, nickname: invoiceData.business.nickname,
items: invoiceData.items?.map((item) => ({ email: invoiceData.business.email,
id: item.id, }
hours: item.hours, : undefined,
rate: item.rate, items: invoiceData.items?.map((item) => ({
})), id: item.id,
} date: item.date,
description: item.description,
hours: item.hours,
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>
</> </>
)} )}
. ?
{retryCount > 0 && (
<div className="text-muted-foreground mt-2 text-sm">
Retry attempt {retryCount} of 2
</div>
)}
</DialogDescription> </DialogDescription>
{retryCount > 0 && (
<p className="text-muted-foreground text-sm">
Retry attempt {retryCount} of 2
</p>
)}
</DialogHeader> </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>
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
@@ -584,8 +639,7 @@ export default function SendEmailPage() {
Cancel Cancel
</Button> </Button>
<Button onClick={confirmSendEmail} variant="default"> <Button onClick={confirmSendEmail} variant="default">
<Send className="mr-2 h-4 w-4" /> Confirm
Send Email
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -23,7 +23,15 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { Eye, Edit, Trash2, FileText, CheckCircle, Send, ChevronDown } from "lucide-react"; import {
Eye,
Edit,
Trash2,
FileText,
CheckCircle,
Send,
ChevronDown,
} from "lucide-react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
@@ -45,11 +53,28 @@ interface Invoice {
createdById: string; createdById: string;
createdAt: Date; createdAt: Date;
updatedAt: Date | null; updatedAt: Date | null;
client?: { id: string; name: string; email: string | null; phone: string | null } | null; client?: {
business?: { id: string; name: string; email: string | null; phone: string | null } | null; id: string;
name: string;
email: string | null;
phone: string | null;
} | null;
business?: {
id: string;
name: string;
email: string | null;
phone: string | null;
} | null;
items?: Array<{ items?: Array<{
id: string; invoiceId: string; date: Date; description: string; id: string;
hours: number; rate: number; amount: number; position: number; createdAt: Date; invoiceId: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
position: number;
createdAt: Date;
}> | null; }> | null;
} }
@@ -58,10 +83,17 @@ interface InvoicesDataTableProps {
} }
const getStatusType = (invoice: Invoice): StatusType => const getStatusType = (invoice: Invoice): StatusType =>
getEffectiveInvoiceStatus(invoice.status as StoredInvoiceStatus, invoice.dueDate) as StatusType; getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
const formatDate = (date: Date) => const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { month: "short", day: "2-digit", year: "numeric" }).format(new Date(date)); new Intl.DateTimeFormat("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
}).format(new Date(date));
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const router = useRouter(); const router = useRouter();
@@ -84,7 +116,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const bulkDelete = api.invoices.bulkDelete.useMutation({ const bulkDelete = api.invoices.bulkDelete.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
toast.success(`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`); toast.success(
`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`,
);
void utils.invoices.getAll.invalidate(); void utils.invoices.getAll.invalidate();
setBulkDeleteDialogOpen(false); setBulkDeleteDialogOpen(false);
setPendingBulkDelete([]); setPendingBulkDelete([]);
@@ -94,7 +128,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({ const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
toast.success(`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`); toast.success(
`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`,
);
void utils.invoices.getAll.invalidate(); void utils.invoices.getAll.invalidate();
}, },
onError: (e) => toast.error(e.message ?? "Failed to update invoices"), onError: (e) => toast.error(e.message ?? "Failed to update invoices"),
@@ -105,7 +141,10 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
id: "select", id: "select",
header: ({ table }) => ( header: ({ table }) => (
<Checkbox <Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="Select all" aria-label="Select all"
data-action-button="true" data-action-button="true"
@@ -124,7 +163,9 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
}, },
{ {
accessorKey: "client.name", accessorKey: "client.name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Client" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Client" />
),
cell: ({ row }) => { cell: ({ row }) => {
const invoice = row.original; const invoice = row.original;
return ( return (
@@ -133,10 +174,17 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<FileText className="text-primary h-4 w-4" /> <FileText className="text-primary h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-medium">{invoice.client?.name ?? "—"}</p> <p className="truncate font-medium">
<p className="text-muted-foreground truncate text-xs sm:text-sm">{invoice.invoiceNumber}</p> {invoice.client?.name ?? "—"}
</p>
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{invoice.invoiceNumber}
</p>
<div className="mt-1 flex items-center gap-2 sm:hidden"> <div className="mt-1 flex items-center gap-2 sm:hidden">
<StatusBadge status={getStatusType(invoice)} className="text-xs" /> <StatusBadge
status={getStatusType(invoice)}
className="text-xs"
/>
<span className="text-foreground text-xs font-semibold"> <span className="text-foreground text-xs font-semibold">
{formatCurrency(invoice.totalAmount, invoice.currency)} {formatCurrency(invoice.totalAmount, invoice.currency)}
</span> </span>
@@ -148,38 +196,59 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
}, },
{ {
accessorKey: "issueDate", accessorKey: "issueDate",
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Date" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm">{formatDate(row.getValue("issueDate") as Date)}</p> <p className="truncate text-sm">
<p className="text-muted-foreground truncate text-xs">Due {formatDate(new Date(row.original.dueDate))}</p> {formatDate(row.getValue("issueDate"))}
</p>
<p className="text-muted-foreground truncate text-xs">
Due {formatDate(new Date(row.original.dueDate))}
</p>
</div> </div>
), ),
}, },
{ {
accessorKey: "status", accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<StatusBadge <StatusBadge
status={getStatusType(row.original)} status={getStatusType(row.original)}
className={getStatusType(row.original) === "sent" ? "status-pending" : ""} className={
getStatusType(row.original) === "sent" ? "status-pending" : ""
}
/> />
), ),
filterFn: (row, _id, value: string[]) => value.includes(getStatusType(row.original)), filterFn: (row, _id, value: string[]) =>
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" }, value.includes(getStatusType(row.original)),
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
}, },
{ {
accessorKey: "totalAmount", accessorKey: "totalAmount",
header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />, header: ({ column }) => (
<DataTableColumnHeader column={column} title="Amount" />
),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="text-right"> <div className="text-right">
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{formatCurrency(row.getValue("totalAmount") as number, row.original.currency)} {formatCurrency(row.getValue("totalAmount"), row.original.currency)}
</p>
<p className="text-muted-foreground text-xs">
{row.original.items?.length ?? 0} items
</p> </p>
<p className="text-muted-foreground text-xs">{row.original.items?.length ?? 0} items</p>
</div> </div>
), ),
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" }, meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
}, },
{ {
id: "actions", id: "actions",
@@ -188,19 +257,34 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
return ( return (
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Link href={`/dashboard/invoices/${invoice.id}`}> <Link href={`/dashboard/invoices/${invoice.id}`}>
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true"> <Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
>
<Eye className="h-3.5 w-3.5" /> <Eye className="h-3.5 w-3.5" />
</Button> </Button>
</Link> </Link>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}> <Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true"> <Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
>
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
</Button> </Button>
</Link> </Link>
<Button <Button
variant="ghost" size="sm" variant="ghost"
size="sm"
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0" className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
onClick={(e) => { e.stopPropagation(); setInvoiceToDelete(invoice); setDeleteDialogOpen(true); }} onClick={(e) => {
e.stopPropagation();
setInvoiceToDelete(invoice);
setDeleteDialogOpen(true);
}}
data-action-button="true" data-action-button="true"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -237,12 +321,18 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
searchKey="invoiceNumber" searchKey="invoiceNumber"
searchPlaceholder="Search invoices..." searchPlaceholder="Search invoices..."
filterableColumns={filterableColumns} filterableColumns={filterableColumns}
onRowClick={(invoice) => router.push(`/dashboard/invoices/${invoice.id}`)} onRowClick={(invoice) =>
router.push(`/dashboard/invoices/${invoice.id}`)
}
selectionActions={(selected, clear) => ( selectionActions={(selected, clear) => (
<> <>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={bulkUpdateStatus.isPending}> <Button
variant="outline"
size="sm"
disabled={bulkUpdateStatus.isPending}
>
<Send className="mr-1.5 h-3.5 w-3.5" /> <Send className="mr-1.5 h-3.5 w-3.5" />
Mark as Mark as
<ChevronDown className="ml-1.5 h-3.5 w-3.5" /> <ChevronDown className="ml-1.5 h-3.5 w-3.5" />
@@ -306,16 +396,24 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<DialogDescription> <DialogDescription>
Are you sure you want to delete invoice{" "} Are you sure you want to delete invoice{" "}
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "} <strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
<strong>{invoiceToDelete?.client?.name}</strong>? This action cannot be undone. <strong>{invoiceToDelete?.client?.name}</strong>? This action
cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleteInvoice.isPending}> <Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={deleteInvoice.isPending}
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => invoiceToDelete && deleteInvoice.mutate({ id: invoiceToDelete.id })} onClick={() =>
invoiceToDelete &&
deleteInvoice.mutate({ id: invoiceToDelete.id })
}
disabled={deleteInvoice.isPending} disabled={deleteInvoice.isPending}
> >
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"} {deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
@@ -325,25 +423,40 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
</Dialog> </Dialog>
{/* Bulk delete dialog */} {/* Bulk delete dialog */}
<Dialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}> <Dialog
open={bulkDeleteDialogOpen}
onOpenChange={setBulkDeleteDialogOpen}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete {pendingBulkDelete.length} Invoice{pendingBulkDelete.length !== 1 ? "s" : ""}</DialogTitle> <DialogTitle>
Delete {pendingBulkDelete.length} Invoice
{pendingBulkDelete.length !== 1 ? "s" : ""}
</DialogTitle>
<DialogDescription> <DialogDescription>
This will permanently delete {pendingBulkDelete.length} invoice{pendingBulkDelete.length !== 1 ? "s" : ""}. This will permanently delete {pendingBulkDelete.length} invoice
This action cannot be undone. {pendingBulkDelete.length !== 1 ? "s" : ""}. This action cannot be
undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setBulkDeleteDialogOpen(false)} disabled={bulkDelete.isPending}> <Button
variant="outline"
onClick={() => setBulkDeleteDialogOpen(false)}
disabled={bulkDelete.isPending}
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })} onClick={() =>
bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })
}
disabled={bulkDelete.isPending} disabled={bulkDelete.isPending}
> >
{bulkDelete.isPending ? "Deleting..." : `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`} {bulkDelete.isPending
? "Deleting..."
: `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+7 -7
View File
@@ -29,7 +29,7 @@ function FormatInstructions() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="bg-muted/50 p-4"> <div className="bg-muted/50 p-4">
<p className="text-muted-foreground font-mono text-sm"> <p className="text-muted-foreground font-mono text-sm">
DATE,DESCRIPTION,HOURS,RATE,AMOUNT DATE,DESCRIPTION,HOURS,RATE,AMOUNT
</p> </p>
@@ -85,7 +85,7 @@ function FormatInstructions() {
for importing time entries. for importing time entries.
</p> </p>
<div className="bg-primary/10 p-4"> <div className="bg-primary/10 p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Info className="text-primary mt-0.5 h-5 w-5" /> <Info className="text-primary mt-0.5 h-5 w-5" />
<div> <div>
@@ -100,7 +100,7 @@ function FormatInstructions() {
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Row:</h4> <h4 className="text-sm font-semibold">Sample Row:</h4>
<div className="bg-muted/50 p-3"> <div className="bg-muted/50 p-3">
<p className="text-muted font-mono text-xs break-all"> <p className="text-muted font-mono text-xs break-all">
1/15/24,&quot;Web development work&quot;,8,75.00,600.00 1/15/24,&quot;Web development work&quot;,8,75.00,600.00
</p> </p>
@@ -109,7 +109,7 @@ function FormatInstructions() {
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Filename:</h4> <h4 className="text-sm font-semibold">Sample Filename:</h4>
<div className="bg-muted/50 p-3"> <div className="bg-muted/50 p-3">
<p className="text-muted font-mono text-xs">2024-01-15.csv</p> <p className="text-muted font-mono text-xs">2024-01-15.csv</p>
</div> </div>
</div> </div>
@@ -168,7 +168,7 @@ function FileFormatHelp() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<div className="bg-accent mx-auto w-fit p-3"> <div className="bg-accent mx-auto w-fit p-3">
<FileSpreadsheet className="text-foreground-foreground h-6 w-6" /> <FileSpreadsheet className="text-foreground-foreground h-6 w-6" />
</div> </div>
<h4 className="font-semibold">CSV Files</h4> <h4 className="font-semibold">CSV Files</h4>
@@ -178,7 +178,7 @@ function FileFormatHelp() {
</p> </p>
</div> </div>
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<div className="bg-primary/10 mx-auto w-fit p-3"> <div className="bg-primary/10 mx-auto w-fit p-3">
<Upload className="text-primary h-6 w-6" /> <Upload className="text-primary h-6 w-6" />
</div> </div>
<h4 className="font-semibold">Max Size</h4> <h4 className="font-semibold">Max Size</h4>
@@ -187,7 +187,7 @@ function FileFormatHelp() {
</p> </p>
</div> </div>
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<div className="bg-secondary mx-auto w-fit p-3"> <div className="bg-secondary mx-auto w-fit p-3">
<CheckCircle className="text-muted-foreground-foreground h-6 w-6" /> <CheckCircle className="text-muted-foreground-foreground h-6 w-6" />
</div> </div>
<h4 className="font-semibold">Validation</h4> <h4 className="font-semibold">Validation</h4>
+185 -74
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { api } from "~/trpc/react"; import { api, type RouterOutputs } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header"; import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
@@ -18,12 +18,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "~/components/ui/tabs";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react"; import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
@@ -34,87 +29,81 @@ interface TemplateForm {
isDefault: boolean; isDefault: boolean;
} }
const defaultForm: TemplateForm = { name: "", type: "notes", content: "", isDefault: false }; const defaultForm: TemplateForm = {
name: "",
type: "notes",
content: "",
isDefault: false,
};
export default function TemplatesPage() { type InvoiceTemplate = RouterOutputs["invoiceTemplates"]["getAll"][number];
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<TemplateForm>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [tab, setTab] = useState<"notes" | "terms">("notes");
const utils = api.useUtils(); interface TemplateListProps {
const { data: templates = [], isLoading } = api.invoiceTemplates.getAll.useQuery(); items: InvoiceTemplate[];
type: "notes" | "terms";
isLoading: boolean;
onCreate: (type: "notes" | "terms") => void;
onEdit: (template: InvoiceTemplate) => void;
onDelete: (id: string) => void;
}
const create = api.invoiceTemplates.create.useMutation({ function TemplateList({
onSuccess: () => { toast.success("Template created"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setForm(defaultForm); }, items,
onError: (e) => toast.error(e.message), type,
}); isLoading,
const update = api.invoiceTemplates.update.useMutation({ onCreate,
onSuccess: () => { toast.success("Template updated"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); }, onEdit,
onError: (e) => toast.error(e.message), onDelete,
}); }: TemplateListProps) {
const del = api.invoiceTemplates.delete.useMutation({ return (
onSuccess: () => { toast.success("Template deleted"); void utils.invoiceTemplates.getAll.invalidate(); setDeleteId(null); },
onError: (e) => toast.error(e.message),
});
const handleOpen = (type: "notes" | "terms") => {
setEditId(null);
setForm({ ...defaultForm, type });
setOpen(true);
};
const handleEdit = (t: typeof templates[0]) => {
setEditId(t.id);
setForm({ name: t.name, type: t.type as "notes" | "terms", content: t.content, isDefault: t.isDefault });
setOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) { toast.error("Name is required"); return; }
if (!form.content.trim()) { toast.error("Content is required"); return; }
if (editId) update.mutate({ id: editId, ...form });
else create.mutate(form);
};
const notesTemplates = templates.filter((t) => t.type === "notes");
const termsTemplates = templates.filter((t) => t.type === "terms");
const TemplateList = ({ items, type }: { items: typeof templates; type: "notes" | "terms" }) => (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-end"> <div className="flex justify-end">
<Button size="sm" onClick={() => handleOpen(type)}> <Button size="sm" onClick={() => onCreate(type)}>
<Plus className="mr-1.5 h-3.5 w-3.5" /> New {type === "notes" ? "Notes" : "Terms"} Template <Plus className="mr-1.5 h-3.5 w-3.5" /> New{" "}
{type === "notes" ? "Notes" : "Terms"} Template
</Button> </Button>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm">Loading</div> <div className="text-muted-foreground py-8 text-center text-sm">
Loading...
</div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="text-muted-foreground py-8 text-center text-sm">
No {type} templates yet. No {type} templates yet.
</div> </div>
) : ( ) : (
items.map((t) => ( items.map((template) => (
<Card key={t.id}> <Card key={template.id}>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-medium">{t.name}</p> <p className="font-medium">{template.name}</p>
{t.isDefault && ( {template.isDefault && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
<Star className="mr-1 h-3 w-3" /> Default <Star className="mr-1 h-3 w-3" /> Default
</Badge> </Badge>
)} )}
</div> </div>
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap"> <p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap">
{t.content} {template.content}
</p> </p>
</div> </div>
<div className="flex flex-shrink-0 gap-1"> <div className="flex flex-shrink-0 gap-1">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(t)}> <Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onEdit(template)}
>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(t.id)}> <Button
variant="ghost"
size="sm"
className="text-destructive h-8 w-8 p-0"
onClick={() => onDelete(template.id)}
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -125,6 +114,77 @@ export default function TemplatesPage() {
)} )}
</div> </div>
); );
}
export default function TemplatesPage() {
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<TemplateForm>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [tab, setTab] = useState<"notes" | "terms">("notes");
const utils = api.useUtils();
const { data: templates = [], isLoading } =
api.invoiceTemplates.getAll.useQuery();
const create = api.invoiceTemplates.create.useMutation({
onSuccess: () => {
toast.success("Template created");
void utils.invoiceTemplates.getAll.invalidate();
setOpen(false);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message),
});
const update = api.invoiceTemplates.update.useMutation({
onSuccess: () => {
toast.success("Template updated");
void utils.invoiceTemplates.getAll.invalidate();
setOpen(false);
setEditId(null);
setForm(defaultForm);
},
onError: (e) => toast.error(e.message),
});
const del = api.invoiceTemplates.delete.useMutation({
onSuccess: () => {
toast.success("Template deleted");
void utils.invoiceTemplates.getAll.invalidate();
setDeleteId(null);
},
onError: (e) => toast.error(e.message),
});
const handleOpen = (type: "notes" | "terms") => {
setEditId(null);
setForm({ ...defaultForm, type });
setOpen(true);
};
const handleEdit = (t: InvoiceTemplate) => {
setEditId(t.id);
setForm({
name: t.name,
type: t.type as "notes" | "terms",
content: t.content,
isDefault: t.isDefault,
});
setOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) {
toast.error("Name is required");
return;
}
if (!form.content.trim()) {
toast.error("Content is required");
return;
}
if (editId) update.mutate({ id: editId, ...form });
else create.mutate(form);
};
const notesTemplates = templates.filter((t) => t.type === "notes");
const termsTemplates = templates.filter((t) => t.type === "terms");
return ( return (
<div className="page-enter space-y-6 pb-6"> <div className="page-enter space-y-6 pb-6">
@@ -137,17 +197,33 @@ export default function TemplatesPage() {
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}> <Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes"> <TabsTrigger value="notes">
<FileText className="mr-1.5 h-4 w-4" /> Notes ({notesTemplates.length}) <FileText className="mr-1.5 h-4 w-4" /> Notes (
{notesTemplates.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="terms"> <TabsTrigger value="terms">
<FileText className="mr-1.5 h-4 w-4" /> Terms ({termsTemplates.length}) <FileText className="mr-1.5 h-4 w-4" /> Terms (
{termsTemplates.length})
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="notes" className="mt-4"> <TabsContent value="notes" className="mt-4">
<TemplateList items={notesTemplates} type="notes" /> <TemplateList
items={notesTemplates}
type="notes"
isLoading={isLoading}
onCreate={handleOpen}
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent> </TabsContent>
<TabsContent value="terms" className="mt-4"> <TabsContent value="terms" className="mt-4">
<TemplateList items={termsTemplates} type="terms" /> <TemplateList
items={termsTemplates}
type="terms"
isLoading={isLoading}
onCreate={handleOpen}
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -155,16 +231,29 @@ export default function TemplatesPage() {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{editId ? "Edit Template" : "New Template"}</DialogTitle> <DialogTitle>
{editId ? "Edit Template" : "New Template"}
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>Name *</Label> <Label>Name *</Label>
<Input value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Standard Payment Terms" /> <Input
value={form.name}
onChange={(e) =>
setForm((p) => ({ ...p, name: e.target.value }))
}
placeholder="e.g. Standard Payment Terms"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Type</Label> <Label>Type</Label>
<Tabs value={form.type} onValueChange={(v) => setForm((p) => ({ ...p, type: v as "notes" | "terms" }))}> <Tabs
value={form.type}
onValueChange={(v) =>
setForm((p) => ({ ...p, type: v as "notes" | "terms" }))
}
>
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes">Notes</TabsTrigger> <TabsTrigger value="notes">Notes</TabsTrigger>
<TabsTrigger value="terms">Terms</TabsTrigger> <TabsTrigger value="terms">Terms</TabsTrigger>
@@ -175,20 +264,36 @@ export default function TemplatesPage() {
<Label>Content *</Label> <Label>Content *</Label>
<Textarea <Textarea
value={form.content} value={form.content}
onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} onChange={(e) =>
setForm((p) => ({ ...p, content: e.target.value }))
}
placeholder="Template content…" placeholder="Template content…"
className="min-h-[120px]" className="min-h-[120px]"
/> />
</div> </div>
<label className="flex cursor-pointer items-center gap-2"> <label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.isDefault} onCheckedChange={(v) => setForm((p) => ({ ...p, isDefault: !!v }))} /> <Checkbox
checked={form.isDefault}
onCheckedChange={(v) =>
setForm((p) => ({ ...p, isDefault: !!v }))
}
/>
<span className="text-sm">Set as default for {form.type}</span> <span className="text-sm">Set as default for {form.type}</span>
</label> </label>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="outline" onClick={() => setOpen(false)}>
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}> Cancel
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Create"} </Button>
<Button
onClick={handleSubmit}
disabled={create.isPending || update.isPending}
>
{create.isPending || update.isPending
? "Saving…"
: editId
? "Update"
: "Create"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -202,8 +307,14 @@ export default function TemplatesPage() {
<DialogDescription>This action cannot be undone.</DialogDescription> <DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button> <Button variant="outline" onClick={() => setDeleteId(null)}>
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}> Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && del.mutate({ id: deleteId })}
disabled={del.isPending}
>
{del.isPending ? "Deleting…" : "Delete"} {del.isPending ? "Deleting…" : "Delete"}
</Button> </Button>
</DialogFooter> </DialogFooter>
+12 -7
View File
@@ -28,9 +28,9 @@ import type { DashboardStats, RecentInvoice } from "./types";
// Hero section with clean mono design // Hero section with clean mono design
// Enhanced stats cards with better visuals // Enhanced stats cards with better visuals
function DashboardStats({ stats }: { stats: DashboardStats }) { // TODO: Import RouterOutput type function DashboardStats({ stats }: { stats: DashboardStats }) {
// TODO: Import RouterOutput type
const formatTrend = (value: number, isCount = false) => { const formatTrend = (value: number, isCount = false) => {
if (isCount) { if (isCount) {
return value > 0 ? `+${value}` : value.toString(); return value > 0 ? `+${value}` : value.toString();
@@ -193,10 +193,11 @@ function QuickActions() {
<Link <Link
key={action.title} key={action.title}
href={action.href} href={action.href}
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${action.featured className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${
? "border-foreground/20 bg-muted/50 hover:bg-muted" action.featured
: "border-border bg-background hover:bg-muted/50" ? "border-foreground/20 bg-muted/50 hover:bg-muted"
}`} : "border-border bg-background hover:bg-muted/50"
}`}
> >
<Icon className="h-5 w-5 flex-shrink-0" /> <Icon className="h-5 w-5 flex-shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -310,7 +311,11 @@ async function CurrentWork() {
} }
// Enhanced recent activity // Enhanced recent activity
async function RecentActivity({ recentInvoices }: { recentInvoices: RecentInvoice[] }) { async function RecentActivity({
recentInvoices,
}: {
recentInvoices: RecentInvoice[];
}) {
// Use passed recentInvoices instead of fetching all // Use passed recentInvoices instead of fetching all
const getStatusStyle = (status: string) => { const getStatusStyle = (status: string) => {
File diff suppressed because it is too large Load Diff
@@ -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,47 +659,623 @@ 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">
<LinkIcon className="text-primary h-5 w-5" /> <LinkIcon className="text-primary h-5 w-5" />
Connected Accounts Connected Accounts
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Manage your linked social accounts and SSO providers Manage your linked social accounts and SSO providers
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10">
<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">
<p className="text-muted-foreground text-sm"> Authentik SSO
Connect your corporate account </p>
</p> <p className="text-muted-foreground text-sm">
Connect your corporate account
</p>
</div>
</div> </div>
<Button
variant="outline"
disabled={isLinking}
onClick={handleLinkAuthentik}
>
{isLinking ? "Connecting..." : "Connect"}
</Button>
</div> </div>
<Button
variant="outline"
disabled={isLinking}
onClick={handleLinkAuthentik}
>
{isLinking ? "Connecting..." : "Connect"}
</Button>
</div> </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"
+5 -10
View File
@@ -3,7 +3,6 @@ import { HydrateClient } from "~/trpc/server";
import { PageHeader } from "~/components/layout/page-header"; import { PageHeader } from "~/components/layout/page-header";
import { DataTableSkeleton } from "~/components/data/data-table"; import { DataTableSkeleton } from "~/components/data/data-table";
import { SettingsContent } from "./_components/settings-content"; import { SettingsContent } from "./_components/settings-content";
import { Card, CardContent } from "~/components/ui/card";
export default async function SettingsPage() { export default async function SettingsPage() {
return ( return (
@@ -14,15 +13,11 @@ export default async function SettingsPage() {
variant="gradient" variant="gradient"
/> />
<Card> <HydrateClient>
<CardContent className="p-6"> <Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
<HydrateClient> <SettingsContent />
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}> </Suspense>
<SettingsContent /> </HydrateClient>
</Suspense>
</HydrateClient>
</CardContent>
</Card>
</div> </div>
); );
} }
+84 -21
View File
@@ -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>
<AnimationPreferencesProvider> <AppearanceProvider>
<div className="relative z-10"> <AnimationPreferencesProvider>
{children} <div className="relative z-10">{children}</div>
</div> </AnimationPreferencesProvider>
</AnimationPreferencesProvider> </AppearanceProvider>
<Toaster /> <Toaster />
<UmamiScript /> <UmamiScript />
</TRPCReactProvider> </TRPCReactProvider>
+88 -222
View File
@@ -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> <Link href="/auth/signin">
<Button variant="ghost" size="sm">
Sign in
</Button>
</Link>
{allowRegistration && (
<Link href="/auth/register">
<Button size="sm">Create account</Button>
</Link>
)}
</nav>
</header>
{/* Navigation */} <section className="grid flex-1 items-center gap-10 py-14 md:grid-cols-[1fr_320px] md:py-20">
<nav className="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="max-w-2xl space-y-7">
<div className="mx-auto px-6"> <div className="space-y-4">
<div className="flex h-16 items-center justify-between"> <p className="text-muted-foreground text-sm font-medium">
<Logo /> Personal invoicing
<div className="hidden items-center space-x-8 md:flex"> </p>
<a <h1 className="font-heading text-4xl leading-tight font-bold tracking-normal sm:text-5xl">
href="#features" {brand.name} is a place to make and track invoices.
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors" </h1>
> <p className="text-muted-foreground max-w-xl text-base leading-7 sm:text-lg">
Features Built for one person managing real clients, real work, and the
</a> small admin loop around getting paid.
<a </p>
href="#pricing"
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
>
Pricing
</a>
</div> </div>
<div className="flex items-center space-x-4">
<div className="flex flex-col gap-3 sm:flex-row">
<Link href="/auth/signin"> <Link href="/auth/signin">
<Button <Button size="lg" className="h-11 px-5">
variant="ghost" Open workspace
size="sm" <ArrowRight className="ml-2 h-4 w-4" />
className="text-muted-foreground hover:text-foreground"
>
Sign In
</Button> </Button>
</Link> </Link>
<Link href="/auth/register"> {allowRegistration && (
<Button size="sm" variant="default" className="rounded-xl px-6"> <Link href="/auth/register">
Get Started <Button variant="outline" size="lg" className="h-11 px-5">
</Button> Create account
</Link>
</div>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="relative pt-48 pb-32">
<div className="container mx-auto px-4 text-center">
<div className="mx-auto max-w-4xl">
<Badge className="bg-primary/10 text-primary border-primary/20 mb-8 border px-4 py-1 text-sm rounded-full">
<Zap className="mr-2 h-3.5 w-3.5" />
Completely Free for Everyone
</Badge>
<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>
<p className="text-muted-foreground mx-auto mb-12 max-w-2xl text-xl leading-relaxed font-sans">
Create professional invoices, manage clients, and track payments with a tool that feels as good as it looks.
</p>
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
<Link href="/auth/register">
<Button
size="lg"
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>
</Link>
<a href="#features">
<Button
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 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="flex items-center gap-2">
<Check className="text-primary h-4 w-4" />
<span>No credit card required</span>
</div>
<div className="flex items-center gap-2">
<Check className="text-primary h-4 w-4" />
<span>Setup in 2 minutes</span>
</div>
<div className="flex items-center gap-2">
<Check className="text-primary h-4 w-4" />
<span>Free forever</span>
</div>
</div>
</div>
</div>
</section>
{/* Features Section */}
<section id="features" className="py-24 relative">
<div className="container mx-auto px-4 relative z-10">
<div className="mb-20 text-center">
<h2 className="text-foreground mb-6 text-4xl font-heading font-bold sm:text-5xl">
Everything you need to <span className="italic text-primary">thrive</span>
</h2>
<p className="text-muted-foreground mx-auto max-w-2xl text-lg">
Powerful features wrapped in a calm, focused interface.
</p>
</div>
<div className="grid gap-8 md:grid-cols-3">
{[
{
icon: Rocket,
title: "Quick Setup",
description: "Start creating invoices immediately. No complicated setup required.",
items: ["Simple client management", "Professional templates", "Easy invoice sending"]
},
{
icon: BarChart3,
title: "Payment Tracking",
description: "Keep track of invoice status and monitor your payments effortlessly.",
items: ["Invoice status tracking", "Payment history", "Overdue notifications"]
},
{
icon: Shield,
title: "Professional Features",
description: "Tools that make you look professional and get you paid faster.",
items: ["PDF generation", "Custom tax rates", "Professional numbering"]
}
].map((feature, i) => (
<Card key={i} className="group 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> </Button>
</Link> </Link>
</CardContent> )}
</Card> </div>
</div> </div>
</div>
</section>
{/* Footer */} <div className="border-border bg-card text-card-foreground rounded-xl border p-5 shadow-sm">
<footer className="border-t border-border/40 bg-background/50 backdrop-blur-sm py-12 mt-12"> <div className="space-y-5">
<div className="container mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-6"> <div className="flex items-start gap-3">
<div className="flex items-center gap-3"> <div className="bg-primary/10 text-primary rounded-md p-2">
<Logo size="sm" /> <UserRound className="h-4 w-4" />
<span className="text-sm text-muted-foreground">© 2024 beenvoice</span> </div>
</div> <div>
<div className="flex gap-8 text-sm text-muted-foreground"> <h2 className="text-sm font-semibold">Clients</h2>
<a href="#" className="hover:text-foreground transition-colors">Privacy</a> <p className="text-muted-foreground mt-1 text-sm leading-6">
<a href="#" className="hover:text-foreground transition-colors">Terms</a> Keep the people and businesses you invoice in one place.
<a href="#" className="hover:text-foreground transition-colors">Contact</a> </p>
</div> </div>
</div> </div>
</footer>
</div> <div className="flex items-start gap-3 border-t pt-5">
<div className="bg-primary/10 text-primary rounded-md p-2">
<FileText className="h-4 w-4" />
</div>
<div>
<h2 className="text-sm font-semibold">Invoices</h2>
<p className="text-muted-foreground mt-1 text-sm leading-6">
Draft, send, mark paid, and export the PDF when you need it.
</p>
</div>
</div>
</div>
</div>
</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">
<span>© 2026 {brand.name}</span>
<div className="flex gap-5">
<Link href="/privacy" className="hover:text-foreground">
Privacy
</Link>
<Link href="/terms" className="hover:text-foreground">
Terms
</Link>
</div>
</footer>
</div>
</main>
); );
} }
+14 -14
View File
@@ -4,20 +4,20 @@ import Script from "next/script";
import { env } from "~/env"; import { env } from "~/env";
export function UmamiScript() { export function UmamiScript() {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
return null; return null;
} }
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) { if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
return null; return null;
} }
return ( return (
<Script <Script
defer defer
src={env.NEXT_PUBLIC_UMAMI_SCRIPT_URL} src={env.NEXT_PUBLIC_UMAMI_SCRIPT_URL}
data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID} data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
strategy="afterInteractive" strategy="afterInteractive"
/> />
); );
} }
@@ -64,7 +64,7 @@ export function AddressAutocomplete({
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
/> />
{showSuggestions && suggestions.length > 0 && ( {showSuggestions && suggestions.length > 0 && (
<Card className="bg-card border-border border absolute z-10 mt-1 max-h-60 w-full overflow-auto"> <Card className="bg-card border-border absolute z-10 mt-1 max-h-60 w-full overflow-auto border">
<ul> <ul>
{suggestions.map((s) => ( {suggestions.map((s) => (
<li <li
+57 -9
View File
@@ -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>
+6 -9
View File
@@ -460,7 +460,7 @@ export function CSVImportPage() {
applyGlobalClient(newClientId); applyGlobalClient(newClientId);
} }
}} }}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-12 w-full border px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-12 w-full border px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={loadingClients} disabled={loadingClients}
> >
<option value="">No default client (select individually)</option> <option value="">No default client (select individually)</option>
@@ -506,7 +506,7 @@ export function CSVImportPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="bg-primary/10 grid grid-cols-2 gap-4 p-4 md:grid-cols-4"> <div className="bg-primary/10 grid grid-cols-2 gap-4 p-4 md:grid-cols-4">
<div className="text-center"> <div className="text-center">
<div className="text-primary text-2xl font-bold"> <div className="text-primary text-2xl font-bold">
{totalFiles} {totalFiles}
@@ -556,10 +556,7 @@ export function CSVImportPage() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{files.map((fileData, index) => ( {files.map((fileData, index) => (
<div <div key={index} className="border-border bg-card border p-4">
key={index}
className="border-border bg-card border p-4"
>
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<FileText className="text-primary h-5 w-5" /> <FileText className="text-primary h-5 w-5" />
@@ -619,7 +616,7 @@ export function CSVImportPage() {
onChange={(e) => onChange={(e) =>
updateFileData(index, { clientId: e.target.value }) updateFileData(index, { clientId: e.target.value })
} }
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={loadingClients} disabled={loadingClients}
> >
<option value="">Select Client</option> <option value="">Select Client</option>
@@ -662,7 +659,7 @@ export function CSVImportPage() {
{/* Error Display */} {/* Error Display */}
{fileData.errors.length > 0 && ( {fileData.errors.length > 0 && (
<div className="border-destructive/20 bg-destructive/10 mt-4 border p-3"> <div className="border-destructive/20 bg-destructive/10 mt-4 border p-3">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<AlertCircle className="text-destructive h-4 w-4" /> <AlertCircle className="text-destructive h-4 w-4" />
<span className="text-destructive text-sm font-medium"> <span className="text-destructive text-sm font-medium">
@@ -772,7 +769,7 @@ export function CSVImportPage() {
{/* Preview Modal */} {/* Preview Modal */}
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}> <Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
<DialogContent className="bg-card border-border border flex max-h-[90vh] max-w-4xl flex-col"> <DialogContent className="bg-card border-border flex max-h-[90vh] max-w-4xl flex-col border">
<DialogHeader className="flex-shrink-0"> <DialogHeader className="flex-shrink-0">
<DialogTitle className="text-foreground flex items-center gap-2 text-xl font-bold"> <DialogTitle className="text-foreground flex items-center gap-2 text-xl font-bold">
<FileText className="text-primary h-5 w-5" /> <FileText className="text-primary h-5 w-5" />
+35 -42
View File
@@ -3,6 +3,7 @@
import type { import type {
ColumnDef, ColumnDef,
ColumnFiltersState, ColumnFiltersState,
RowData,
SortingState, SortingState,
VisibilityState, VisibilityState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
@@ -53,6 +54,14 @@ import {
} from "~/components/ui/table"; } from "~/components/ui/table";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Generic names must match TanStack's declaration for module augmentation.
interface ColumnMeta<TData extends RowData, TValue> {
headerClassName?: string;
cellClassName?: string;
}
}
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
data: TData[]; data: TData[];
@@ -125,23 +134,9 @@ export function DataTable<TData, TValue>({
...column, ...column,
// Add a meta property to control responsive visibility // Add a meta property to control responsive visibility
meta: { meta: {
...(( ...(column.meta ?? {}),
column as ColumnDef<TData, TValue> & { headerClassName: column.meta?.headerClassName ?? "",
meta?: { headerClassName?: string; cellClassName?: string }; cellClassName: column.meta?.cellClassName ?? "",
}
).meta ?? {}),
headerClassName:
(
column as ColumnDef<TData, TValue> & {
meta?: { headerClassName?: string; cellClassName?: string };
}
).meta?.headerClassName ?? "",
cellClassName:
(
column as ColumnDef<TData, TValue> & {
meta?: { headerClassName?: string; cellClassName?: string };
}
).meta?.cellClassName ?? "",
}, },
})); }));
}, [columns]); }, [columns]);
@@ -369,9 +364,7 @@ export function DataTable<TData, TValue>({
className="bg-muted/50 hover:bg-muted/50" className="bg-muted/50 hover:bg-muted/50"
> >
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as const meta = header.column.columnDef.meta;
| { headerClassName?: string; cellClassName?: string }
| undefined;
return ( return (
<TableHead <TableHead
key={header.id} key={header.id}
@@ -383,9 +376,9 @@ export function DataTable<TData, TValue>({
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext(), header.getContext(),
)} )}
</TableHead> </TableHead>
); );
})} })}
@@ -407,9 +400,7 @@ export function DataTable<TData, TValue>({
} }
> >
{row.getVisibleCells().map((cell) => { {row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as const meta = cell.column.columnDef.meta;
| { headerClassName?: string; cellClassName?: string }
| undefined;
return ( return (
<TableCell <TableCell
key={cell.id} key={cell.id}
@@ -451,26 +442,28 @@ export function DataTable<TData, TValue>({
<p className="text-muted-foreground hidden text-xs sm:inline sm:text-sm"> <p className="text-muted-foreground hidden text-xs sm:inline sm:text-sm">
{table.getFilteredRowModel().rows.length === 0 {table.getFilteredRowModel().rows.length === 0
? "No entries" ? "No entries"
: `Showing ${table.getState().pagination.pageIndex * : `Showing ${
table.getState().pagination.pageSize + table.getState().pagination.pageIndex *
1 table.getState().pagination.pageSize +
} to ${Math.min( 1
(table.getState().pagination.pageIndex + 1) * } to ${Math.min(
table.getState().pagination.pageSize, (table.getState().pagination.pageIndex + 1) *
table.getFilteredRowModel().rows.length, table.getState().pagination.pageSize,
)} of ${table.getFilteredRowModel().rows.length} entries`} table.getFilteredRowModel().rows.length,
)} of ${table.getFilteredRowModel().rows.length} entries`}
</p> </p>
<p className="text-muted-foreground text-xs sm:hidden"> <p className="text-muted-foreground text-xs sm:hidden">
{table.getFilteredRowModel().rows.length === 0 {table.getFilteredRowModel().rows.length === 0
? "0" ? "0"
: `${table.getState().pagination.pageIndex * : `${
table.getState().pagination.pageSize + table.getState().pagination.pageIndex *
1 table.getState().pagination.pageSize +
}-${Math.min( 1
(table.getState().pagination.pageIndex + 1) * }-${Math.min(
table.getState().pagination.pageSize, (table.getState().pagination.pageIndex + 1) *
table.getFilteredRowModel().rows.length, table.getState().pagination.pageSize,
)} of ${table.getFilteredRowModel().rows.length}`} table.getFilteredRowModel().rows.length,
)} of ${table.getFilteredRowModel().rows.length}`}
</p> </p>
<Select <Select
value={table.getState().pagination.pageSize.toString()} value={table.getState().pagination.pageSize.toString()}
@@ -87,8 +87,9 @@ function SortableItem({
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={`card-secondary transition-colors ${isDragging ? "opacity-50 shadow-lg" : "" className={`card-secondary transition-colors ${
}`} isDragging ? "opacity-50 shadow-lg" : ""
}`}
> >
{/* Desktop Layout - Hidden on Mobile */} {/* Desktop Layout - Hidden on Mobile */}
<div className="hidden items-center gap-3 p-4 md:grid md:grid-cols-12"> <div className="hidden items-center gap-3 p-4 md:grid md:grid-cols-12">
@@ -153,7 +154,7 @@ function SortableItem({
{/* Amount */} {/* Amount */}
<div className="col-span-1"> <div className="col-span-1">
<div className="bg-muted/30 text-primary flex h-9 items-center border px-3 font-medium"> <div className="bg-muted/30 text-primary flex h-9 items-center border px-3 font-medium">
${item.amount.toFixed(2)} ${item.amount.toFixed(2)}
</div> </div>
</div> </div>
@@ -265,7 +266,7 @@ function SortableItem({
</div> </div>
{/* Amount */} {/* Amount */}
<div className="bg-muted/20 border p-3"> <div className="bg-muted/20 border p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">Total Amount:</span> <span className="text-muted-foreground text-sm">Total Amount:</span>
<span className="text-primary font-mono text-lg font-bold"> <span className="text-primary font-mono text-lg font-bold">
@@ -360,10 +361,7 @@ export function EditableInvoiceItems({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{items.map((item, _index) => ( {items.map((item, _index) => (
<div <div key={item.id} className="card-secondary animate-pulse p-4">
key={item.id}
className="card-secondary animate-pulse p-4"
>
{/* Desktop Skeleton */} {/* Desktop Skeleton */}
<div className="hidden grid-cols-12 gap-3 md:grid"> <div className="hidden grid-cols-12 gap-3 md:grid">
<div className="col-span-1"> <div className="col-span-1">
-504
View File
@@ -1,504 +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&apos;re looking for doesn&apos;t exist or has been
deleted.
</p>
<Button asChild>
<Link href="/dashboard/invoices">Back to Invoices</Link>
</Button>
</div>
);
}
const StatusIcon =
statusIconConfig[invoice.status as keyof typeof statusIconConfig];
return (
<div className="space-y-6">
{/* Status Alert */}
{isOverdue && (
<Card className="border-destructive/20 bg-destructive/10">
<CardContent className="p-4">
<div className="text-destructive flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
<span className="font-medium">This invoice is overdue</span>
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 lg:col-span-2">
{/* Invoice Header Card */}
<Card className="bg-card border-border border">
<CardContent>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1 space-y-4">
<div className="flex items-center gap-3">
<div className="bg-primary/10 flex-shrink-0 p-2">
<FileText className="text-primary h-6 w-6" />
</div>
<div className="min-w-0">
<h2 className="text-foreground truncate text-2xl font-bold">
{invoice.invoiceNumber}
</h2>
<p className="text-muted-foreground">
Professional Invoice
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6 text-sm">
<div>
<span className="text-muted-foreground">Issue Date</span>
<p className="text-foreground font-medium">
{formatDate(invoice.issueDate)}
</p>
</div>
<div>
<span className="text-muted-foreground">Due Date</span>
<p className="text-foreground font-medium">
{formatDate(invoice.dueDate)}
</p>
</div>
</div>
</div>
<div className="flex flex-row items-center justify-between gap-3 sm:flex-col sm:items-end sm:text-right">
<div>
<StatusBadge
status={invoice.status as StatusType}
className="px-3 py-1 text-sm font-medium"
>
<StatusIcon className="mr-1 h-3 w-3" />
</StatusBadge>
<div className="text-primary mt-1 text-2xl font-bold sm:text-3xl">
{formatCurrency(invoice.totalAmount)}
</div>
</div>
<Button
onClick={handlePDFExport}
disabled={isExportingPDF}
variant="default"
className="transform-none flex-shrink-0"
>
{isExportingPDF ? (
<>
<div className="mr-2 h-4 w-4 animate-spin border-2 border-white border-t-transparent" />
Generating PDF...
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Download PDF
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Client Information */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-primary flex items-center gap-2">
<User className="h-5 w-5" />
Bill To
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="text-foreground text-lg font-semibold">
{invoice.client?.name}
</h3>
</div>
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
{invoice.client?.email && (
<div className="text-muted-foreground flex items-center gap-2">
<Mail className="text-muted-foreground h-4 w-4" />
{invoice.client.email}
</div>
)}
{invoice.client?.phone && (
<div className="text-muted-foreground flex items-center gap-2">
<Phone className="text-muted-foreground h-4 w-4" />
{invoice.client.phone}
</div>
)}
{(invoice.client?.addressLine1 ??
invoice.client?.city ??
invoice.client?.state) && (
<div className="text-muted-foreground flex items-start gap-2 md:col-span-2">
<MapPin className="text-muted-foreground mt-0.5 h-4 w-4 flex-shrink-0" />
<div>
{invoice.client?.addressLine1 && (
<div>{invoice.client.addressLine1}</div>
)}
{invoice.client?.addressLine2 && (
<div>{invoice.client.addressLine2}</div>
)}
{(invoice.client?.city ??
invoice.client?.state ??
invoice.client?.postalCode) && (
<div>
{[
invoice.client?.city,
invoice.client?.state,
invoice.client?.postalCode,
]
.filter(Boolean)
.join(", ")}
</div>
)}
{invoice.client?.country && (
<div>{invoice.client.country}</div>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Invoice Items */}
<Card className="bg-secondary border-border border">
<CardHeader>
<CardTitle className="text-primary flex items-center gap-2">
<Clock className="h-5 w-5" />
Invoice Items
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{invoice.items?.map((item, index) => (
<div
key={item.id || index}
className="bg-background flex flex-col gap-1 rounded-lg p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 flex-1">
<div className="text-foreground font-medium break-words">
{item.description}
</div>
<div className="text-muted-foreground mt-0.5 text-sm">
{formatDate(item.date)} &middot; {item.hours}h @{" "}
{formatCurrency(item.rate)}/hr
</div>
</div>
<div className="text-foreground flex-shrink-0 font-medium sm:text-right">
{formatCurrency(item.amount)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Notes */}
{invoice.notes && (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-primary">Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground whitespace-pre-wrap">
{invoice.notes}
</p>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Status Actions */}
<Card className="bg-secondary border-border border">
<CardHeader>
<CardTitle className="text-primary">Status Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{invoice.status === "draft" && (
<Button
onClick={() => handleStatusUpdate("sent")}
disabled={updateStatus.isPending}
className="w-full"
>
<Send className="mr-2 h-4 w-4" />
Mark as Sent
</Button>
)}
{invoice.status === "sent" && (
<Button
onClick={() => handleStatusUpdate("paid")}
disabled={updateStatus.isPending}
className="w-full"
>
<DollarSign className="mr-2 h-4 w-4" />
Mark as Paid
</Button>
)}
{invoice.status === "overdue" && (
<Button
onClick={() => handleStatusUpdate("paid")}
disabled={updateStatus.isPending}
className="w-full"
>
<DollarSign className="mr-2 h-4 w-4" />
Mark as Paid
</Button>
)}
{invoice.status === "paid" && (
<div className="py-4 text-center">
<DollarSign className="text-primary mx-auto mb-2 h-8 w-8" />
<p className="text-primary font-medium">Invoice Paid</p>
</div>
)}
</CardContent>
</Card>
{/* Invoice Summary */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-primary">Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal</span>
<span className="text-foreground font-medium">
{formatCurrency(invoice.totalAmount)}
</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>
);
}
+1 -1
View File
@@ -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>
)} )}
+1
View File
@@ -143,6 +143,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Load business data when editing // Load business data when editing
useEffect(() => { useEffect(() => {
if (business && mode === "edit") { if (business && mode === "edit") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
setFormData({ setFormData({
name: business.name, name: business.name,
nickname: business.nickname ?? "", nickname: business.nickname ?? "",
+1
View File
@@ -119,6 +119,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
// Load client data when editing // Load client data when editing
useEffect(() => { useEffect(() => {
if (client && mode === "edit") { if (client && mode === "edit") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form.
setFormData({ setFormData({
name: client.name, name: client.name,
email: client.email ?? "", email: client.email ?? "",
+9 -10
View File
@@ -96,7 +96,7 @@ export function EmailComposer({
content: customMessage, content: customMessage,
immediatelyRender: false, immediatelyRender: false,
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
onCustomMessageChange?.(editor.getHTML()); onCustomMessageChange?.(editor.isEmpty ? "" : editor.getHTML());
}, },
editorProps: { editorProps: {
attributes: { attributes: {
@@ -109,7 +109,7 @@ export function EmailComposer({
// Update editor content when customMessage prop changes // Update editor content when customMessage prop changes
useEffect(() => { useEffect(() => {
if (editor && customMessage !== undefined) { if (editor && customMessage !== undefined) {
const currentContent = editor.getHTML(); const currentContent = editor.isEmpty ? "" : editor.getHTML();
if (currentContent !== customMessage) { if (currentContent !== customMessage) {
editor.commands.setContent(customMessage); editor.commands.setContent(customMessage);
} }
@@ -133,9 +133,9 @@ export function EmailComposer({
if (!editor) { if (!editor) {
return ( return (
<div className="bg-muted flex h-[200px] items-center justify-center border"> <div className="bg-muted flex h-[200px] items-center justify-center border">
<div className="text-center"> <div className="text-center">
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin border-2 border-t-transparent"></div> <div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin border-2 border-t-transparent"></div>
<p className="text-muted-foreground text-sm">Loading editor...</p> <p className="text-muted-foreground text-sm">Loading editor...</p>
</div> </div>
</div> </div>
@@ -145,7 +145,7 @@ export function EmailComposer({
return ( return (
<div className={className}> <div className={className}>
{/* Email Headers */} {/* Email Headers */}
<div className="bg-muted/20 space-y-4 border p-4"> <div className="bg-muted/20 space-y-4 border p-4">
<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 htmlFor="from-email" className="text-sm font-medium"> <Label htmlFor="from-email" className="text-sm font-medium">
@@ -222,16 +222,15 @@ 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>
{/* Editor Toolbar */} {/* Editor Toolbar */}
<div className="bg-muted/20 flex flex-wrap items-center gap-1 border p-2"> <div className="bg-muted/20 flex flex-wrap items-center gap-1 border p-2">
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive("bold")} isActive={editor.isActive("bold")}
+10 -6
View File
@@ -17,6 +17,7 @@ interface EmailPreviewProps {
taxRate: number; taxRate: number;
status?: string; status?: string;
totalAmount?: number; totalAmount?: number;
currency?: string | null;
client?: { client?: {
name: string; name: string;
email: string | null; email: string | null;
@@ -27,8 +28,11 @@ interface EmailPreviewProps {
}; };
items?: Array<{ items?: Array<{
id: string; id: string;
date?: Date;
description?: string;
hours: number; hours: number;
rate: number; rate: number;
amount?: number;
}>; }>;
}; };
className?: string; className?: string;
@@ -66,7 +70,7 @@ export function EmailPreview({
status: invoice.status ?? "draft", status: invoice.status ?? "draft",
totalAmount: invoice.totalAmount ?? calculateTotal(), totalAmount: invoice.totalAmount ?? calculateTotal(),
taxRate: invoice.taxRate, taxRate: invoice.taxRate,
notes: null, currency: invoice.currency,
client: { client: {
name: invoice.client?.name ?? "Client", name: invoice.client?.name ?? "Client",
email: invoice.client?.email ?? null, email: invoice.client?.email ?? null,
@@ -74,11 +78,11 @@ export function EmailPreview({
business: invoice.business ?? null, business: invoice.business ?? null,
items: items:
invoice.items?.map((item) => ({ invoice.items?.map((item) => ({
date: new Date(), date: item.date ?? new Date(),
description: "Service", description: item.description ?? "Service",
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.hours * item.rate, amount: item.amount ?? item.hours * item.rate,
})) ?? [], })) ?? [],
}, },
customContent: content, customContent: content,
@@ -95,7 +99,7 @@ export function EmailPreview({
return ( return (
<div className={className}> <div className={className}>
{/* Email Headers */} {/* Email Headers */}
<div className="bg-muted/20 mb-4 space-y-3 p-4"> <div className="bg-muted/20 mb-4 space-y-3 p-4">
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-3"> <div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-3">
<div> <div>
<span className="text-muted-foreground block text-xs font-medium"> <span className="text-muted-foreground block text-xs font-medium">
@@ -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"
+4 -4
View File
@@ -56,7 +56,7 @@ function FilePreview({
return ( return (
<div <div
className={cn( className={cn(
"flex items-center justify-between border p-3", "flex items-center justify-between border p-3",
getStatusColor(), getStatusColor(),
)} )}
> >
@@ -152,7 +152,7 @@ export function FileUpload({
<div <div
{...getRootProps()} {...getRootProps()}
className={cn( className={cn(
"cursor-pointer border-2 border-dashed p-8 text-center transition-colors", "cursor-pointer border-2 border-dashed p-8 text-center transition-colors",
"hover:border-primary/40 hover:bg-primary/10", "hover:border-primary/40 hover:bg-primary/10",
isDragActive && "border-primary/40 bg-primary/10", isDragActive && "border-primary/40 bg-primary/10",
isDragReject && "border-destructive/40 bg-destructive/10", isDragReject && "border-destructive/40 bg-destructive/10",
@@ -164,7 +164,7 @@ export function FileUpload({
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div <div
className={cn( className={cn(
" p-3 transition-colors", "p-3 transition-colors",
isDragActive ? "bg-primary/10" : "bg-muted", isDragActive ? "bg-primary/10" : "bg-muted",
isDragReject && "bg-destructive/10", isDragReject && "bg-destructive/10",
)} )}
@@ -222,7 +222,7 @@ export function FileUpload({
{/* Error Summary */} {/* Error Summary */}
{Object.keys(errors).length > 0 && ( {Object.keys(errors).length > 0 && (
<div className="border-destructive/20 bg-destructive/10 border p-3"> <div className="border-destructive/20 bg-destructive/10 border p-3">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<AlertCircle className="text-destructive h-4 w-4" /> <AlertCircle className="text-destructive h-4 w-4" />
<span className="text-destructive text-sm font-medium"> <span className="text-destructive text-sm font-medium">
+492 -370
View File
@@ -1,398 +1,520 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { format, startOfWeek, endOfWeek, eachDayOfInterval, isSameDay, subWeeks, addWeeks, subMonths, addMonths } from "date-fns"; import {
format,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameDay,
subWeeks,
addWeeks,
subMonths,
addMonths,
} from "date-fns";
import { Calendar } from "~/components/ui/calendar"; import { Calendar } from "~/components/ui/calendar";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
SheetFooter, SheetFooter,
} from "~/components/ui/sheet"; } from "~/components/ui/sheet";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
import { Plus, Trash2, Clock, Calendar as CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"; import {
Plus,
Trash2,
Clock,
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
interface InvoiceItem { interface InvoiceItem {
id: string; id: string;
date: Date; date: Date;
description: string; description: string;
hours: number; hours: number;
rate: number; rate: number;
amount: number; amount: number;
} }
interface InvoiceCalendarViewProps { interface InvoiceCalendarViewProps {
items: InvoiceItem[]; items: InvoiceItem[];
onUpdateItem: ( onUpdateItem: (
index: number, index: number,
field: string, field: string,
value: string | number | Date value: string | number | Date,
) => void; ) => void;
onAddItem: (date?: Date) => void; onAddItem: (date?: Date) => void;
onRemoveItem: (index: number) => void; onRemoveItem: (index: number) => void;
className?: string; className?: string;
defaultHourlyRate: number | null; defaultHourlyRate: number | null;
} }
export function InvoiceCalendarView({ export function InvoiceCalendarView({
items, items,
onUpdateItem, onUpdateItem,
onAddItem, onAddItem,
onRemoveItem, onRemoveItem,
className, className,
defaultHourlyRate: _defaultHourlyRate, defaultHourlyRate: _defaultHourlyRate,
}: InvoiceCalendarViewProps) { }: InvoiceCalendarViewProps) {
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week) const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
const [view, setView] = React.useState<"month" | "week">("month"); const [view, setView] = React.useState<"month" | "week">("month");
const [sheetOpen, setSheetOpen] = React.useState(false); const [sheetOpen, setSheetOpen] = React.useState(false);
// Derived state for selected date items - solves cursor jumping // Derived state for selected date items - solves cursor jumping
const selectedDateItems = React.useMemo(() => { const selectedDateItems = React.useMemo(() => {
if (!date) return []; if (!date) return [];
return items return items
.map((item, index) => ({ item, index })) .map((item, index) => ({ item, index }))
.filter((wrapper) => { .filter((wrapper) => {
const itemDate = new Date(wrapper.item.date); const itemDate = new Date(wrapper.item.date);
return isSameDay(itemDate, date); return isSameDay(itemDate, date);
}); });
}, [items, date]); }, [items, date]);
// Helper to get items for any date (for calendar view) // Helper to get items for any date (for calendar view)
const getItemsForDate = React.useCallback((targetDate: Date) => { const getItemsForDate = React.useCallback(
return items (targetDate: Date) => {
.map((item, index) => ({ item, index })) return items
.filter((wrapper) => { .map((item, index) => ({ item, index }))
const itemDate = new Date(wrapper.item.date); .filter((wrapper) => {
return isSameDay(itemDate, targetDate); const itemDate = new Date(wrapper.item.date);
}); return isSameDay(itemDate, targetDate);
}, [items]); });
},
[items],
);
const handleSelectDate = (newDate: Date | undefined) => { const handleSelectDate = (newDate: Date | undefined) => {
if (!newDate) return; if (!newDate) return;
setDate(newDate); setDate(newDate);
setSheetOpen(true); setSheetOpen(true);
}; };
const handleAddNewItem = () => { const handleAddNewItem = () => {
if (date) { if (date) {
onAddItem(date); onAddItem(date);
} }
}; };
// Week View Logic - Uses viewDate // Week View Logic - Uses viewDate
const currentWeekStart = startOfWeek(viewDate); const currentWeekStart = startOfWeek(viewDate);
const currentWeekEnd = endOfWeek(viewDate); const currentWeekEnd = endOfWeek(viewDate);
const weekDays = eachDayOfInterval({ start: currentWeekStart, end: currentWeekEnd }); const weekDays = eachDayOfInterval({
start: currentWeekStart,
end: currentWeekEnd,
});
const handleCloseSheet = (isOpen: boolean) => { const handleCloseSheet = (isOpen: boolean) => {
setSheetOpen(isOpen); setSheetOpen(isOpen);
if (!isOpen) { if (!isOpen) {
setDate(undefined); setDate(undefined);
} }
}; };
return ( return (
<div className={cn("flex flex-col gap-4 h-full w-full", className)}> <div className={cn("flex h-full w-full flex-col gap-4", className)}>
<div className="flex items-center justify-between px-4 pt-4 w-full gap-4"> <div className="flex w-full items-center justify-between gap-4 px-4 pt-4">
{/* Navigation Controls */} {/* Navigation Controls */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{view === "week" ? ( {view === "week" ? (
<> <>
<Button variant="outline" size="icon" onClick={() => setViewDate(d => subWeeks(d, 1))} className="h-8 w-8 rounded-lg"> <Button
<ChevronLeft className="h-4 w-4" /> variant="outline"
</Button> size="icon"
<span className="text-sm font-medium w-36 text-center"> onClick={() => setViewDate((d) => subWeeks(d, 1))}
{`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`} className="h-8 w-8 rounded-lg"
</span> >
<Button variant="outline" size="icon" onClick={() => setViewDate(d => addWeeks(d, 1))} className="h-8 w-8 rounded-lg"> <ChevronLeft className="h-4 w-4" />
<ChevronRight className="h-4 w-4" /> </Button>
</Button> <span className="w-36 text-center text-sm font-medium">
</> {`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`}
) : ( </span>
<> <Button
<Button variant="outline" size="icon" onClick={() => setViewDate(d => subMonths(d, 1))} className="h-8 w-8 rounded-lg"> variant="outline"
<ChevronLeft className="h-4 w-4" /> size="icon"
</Button> onClick={() => setViewDate((d) => addWeeks(d, 1))}
<span className="text-sm font-medium w-36 text-center"> className="h-8 w-8 rounded-lg"
{format(viewDate, "MMMM yyyy")} >
</span> <ChevronRight className="h-4 w-4" />
<Button variant="outline" size="icon" onClick={() => setViewDate(d => addMonths(d, 1))} className="h-8 w-8 rounded-lg"> </Button>
<ChevronRight className="h-4 w-4" /> </>
</Button> ) : (
</> <>
)} <Button
</div> variant="outline"
size="icon"
<div className="flex items-center space-x-2 ml-auto"> onClick={() => setViewDate((d) => subMonths(d, 1))}
{/* View Switcher */} className="h-8 w-8 rounded-lg"
<div className="bg-muted p-1 rounded-lg flex text-sm"> >
<button <ChevronLeft className="h-4 w-4" />
type="button" </Button>
onClick={() => setView("month")} <span className="w-36 text-center text-sm font-medium">
className={cn("px-3 py-1.5 rounded-md transition-all text-center font-medium", view === "month" ? "bg-background shadow text-foreground" : "text-muted-foreground hover:text-foreground")} {format(viewDate, "MMMM yyyy")}
> </span>
Month <Button
</button> variant="outline"
<button size="icon"
type="button" onClick={() => setViewDate((d) => addMonths(d, 1))}
onClick={() => setView("week")} className="h-8 w-8 rounded-lg"
className={cn("px-3 py-1.5 rounded-md transition-all text-center font-medium", view === "week" ? "bg-background shadow text-foreground" : "text-muted-foreground hover:text-foreground")} >
> <ChevronRight className="h-4 w-4" />
Week </Button>
</button> </>
</div> )}
</div>
</div>
<div className="flex-1 w-full overflow-hidden">
{view === "month" ? (
<Calendar
mode="single"
selected={date}
onSelect={handleSelectDate}
month={viewDate}
onMonthChange={setViewDate}
className="rounded-md border-0 w-full p-0"
classNames={{
root: "w-full p-0",
months: "flex flex-col w-full",
month: "flex flex-col w-full space-y-4",
// Grid - Revert to Flex but Enforce 1/7th Width
// table: "w-full border-collapse", // No table-fixed
head_row: "flex w-full",
row: "flex w-full mt-2",
// Cells & Headers: Explicit width 14.28%
// Use calc(100%/7) via tailwind arbitrary or just flex bases.
// Better: w-[14.28%] flex-none (approx 1/7)
weekdays: "flex w-full border-b",
weekday: "w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
week: "flex w-full mt-2",
cell: "w-[14.285%] flex-none h-20 sm:h-28 md:h-32 border-b p-0 relative focus-within:relative focus-within:z-20 text-center text-sm",
// Hide internal navigation & caption entirely
nav: "hidden",
caption: "hidden",
day: cn(
"w-full h-full p-2 font-normal aria-selected:opacity-100 flex flex-col items-start justify-start gap-1 hover:bg-accent/50 hover:text-accent-foreground align-top transition-colors rounded-xl"
),
day_selected: "bg-primary/5 text-primary",
day_today: "bg-accent/20",
day_outside: "text-muted-foreground opacity-30",
}}
formatters={{
formatMonthCaption: () => "", // Clear default caption text to prevent duplication
}}
components={{
DayButton: (props) => {
const { day, modifiers, className, ...buttonProps } = props;
const DayDate = day.date;
const dayItems = getItemsForDate(DayDate);
// const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0); // Unused now
return (
<button
{...buttonProps}
type="button"
className={cn(
"relative flex h-full w-full flex-col items-start justify-between p-2 transition-all rounded-xl border border-transparent hover:border-border/50 hover:bg-secondary/30 text-left overflow-hidden",
// Selected State: Filled Box, No Outline
modifiers.selected && "bg-primary text-primary-foreground hover:bg-primary/90 shadow-md transform scale-[0.98]",
modifiers.today && !modifiers.selected && "bg-accent/40 rounded-xl",
className
)}
>
<span className="text-sm font-medium z-10">{DayDate.getDate()}</span>
{dayItems.length > 0 && (
<div className="flex flex-col gap-1 w-full mt-1 overflow-hidden h-full justify-end pb-1">
<div className="flex flex-col gap-1 w-full mt-1">
{dayItems.slice(0, 4).map((item, idx) => (
<div key={idx} className={cn("h-1 w-full rounded-full", modifiers.selected ? "bg-primary-foreground/50" : "bg-primary/50")} />
))}
{dayItems.length > 4 && <div className={cn("h-1 w-1/3 rounded-full", modifiers.selected ? "bg-primary-foreground/30" : "bg-muted-foreground/30")} />}
</div>
</div>
)}
</button>
);
}
}}
/>
) : (
<div className="flex gap-3 overflow-x-auto p-4 pb-6 w-full">
{weekDays.map((day) => {
const isSelected = date && isSameDay(day, date);
const isToday = isSameDay(day, new Date());
const dayItems = getItemsForDate(day);
const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0);
return (
<button
key={day.toString()}
type="button"
onClick={() => handleSelectDate(day)}
className={cn(
"flex flex-col min-h-[260px] flex-shrink-0 w-[120px] sm:flex-1 sm:w-auto border rounded-3xl p-3 text-left transition-all hover:bg-accent/30",
isSelected ? "ring-2 ring-primary ring-offset-2 bg-primary/5" : "bg-background/40",
isToday && !isSelected ? "bg-accent/40" : ""
)}
>
<div className="flex flex-col items-center mb-4 pb-4 border-b w-full">
<span className="text-xs font-bold text-muted-foreground uppercase">{format(day, "EEE")}</span>
<span className="text-2xl font-light">{format(day, "d")}</span>
</div>
<div className="flex-1 space-y-2 w-full overflow-hidden">
{dayItems.length > 0 ? (
dayItems.map(({ item }, i) => (
<div key={i} className="bg-background rounded-xl p-2 text-xs shadow-sm border">
<div className="font-medium line-clamp-2 text-wrap break-words">{item.description || "No description"}</div>
<div className="text-muted-foreground whitespace-nowrap">{item.hours}h</div>
</div>
))
) : (
<div className="h-full flex items-center justify-center text-muted-foreground/20">
<Plus className="w-8 h-8" />
</div>
)}
</div>
{dayItems.length > 0 && (
<div className="pt-2 mt-auto text-center w-full">
<span className="text-sm font-semibold">{totalHours}h Total</span>
</div>
)}
</button>
);
})}
</div>
)}
</div>
{/* Sheet for Day Details */}
<Sheet
open={sheetOpen}
onOpenChange={handleCloseSheet}
>
<SheetContent side="right" className="w-full max-w-full sm:w-[400px] sm:max-w-[540px] flex flex-col gap-0 p-0">
<SheetHeader className="p-6 border-b">
<SheetTitle className="flex items-center gap-3 text-2xl flex-wrap">
<div className="bg-primary/10 p-2.5 rounded-full flex-shrink-0">
<CalendarIcon className="w-6 h-6 text-primary" />
</div>
<span className="break-words text-left">{date ? format(date, "EEEE, MMMM do") : "Details"}</span>
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-6">
{date && selectedDateItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4 bg-secondary/20 rounded-3xl border border-dashed border-border/60">
<div className="bg-background p-4 rounded-full shadow-sm">
<Clock className="w-8 h-8 text-muted-foreground/50" />
</div>
<div className="space-y-1">
<p className="font-semibold text-lg text-foreground">No hours logged</p>
<p className="text-sm text-muted-foreground/80 max-w-[200px]">There are no time entries recorded for this day yet.</p>
</div>
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
<Plus className="w-4 h-4 mr-2" />
Log Time
</Button>
</div>
) : (
<div className="space-y-4">
{selectedDateItems.map(({ item, index }) => (
<div key={item.id} className="border-border bg-card overflow-hidden rounded-lg border group hover:border-primary/50 transition-colors">
<div className="space-y-3 p-4">
{/* Description */}
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Description</Label>
<Input
value={item.description}
onChange={(e) => onUpdateItem(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="pl-3 text-sm"
/>
</div>
{/* Hours and Rate in a row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Hours</Label>
<NumberInput
value={item.hours}
onChange={v => onUpdateItem(index, "hours", v)}
step={0.25}
min={0}
width="full"
/>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Rate</Label>
<NumberInput
value={item.rate}
onChange={v => onUpdateItem(index, "rate", v)}
prefix="$"
min={0}
step={1}
width="full"
/>
</div>
</div>
</div>
{/* Bottom section with controls, item name, and total */}
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemoveItem(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 px-3 text-center">
<span className="text-muted-foreground block text-sm font-medium">
Item #{index + 1}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs">Total</span>
<span className="text-primary text-lg font-bold">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
</div>
</div>
))}
<Button variant="outline" onClick={handleAddNewItem} className="w-full border-dashed py-8 rounded-xl hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary transition-all gap-2 group">
<div className="bg-muted group-hover:bg-primary/10 p-1 rounded-md transition-colors">
<Plus className="w-4 h-4" />
</div>
<span>Add Another Entry</span>
</Button>
</div>
)}
</div>
</div>
<SheetFooter className="p-6 border-t bg-muted/10 mt-auto">
<Button className="w-full sm:w-full rounded-xl h-12 text-base shadow-md" size="lg" onClick={() => handleCloseSheet(false)}>Done</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div> </div>
);
<div className="ml-auto flex items-center space-x-2">
{/* View Switcher */}
<div className="bg-muted flex rounded-lg p-1 text-sm">
<button
type="button"
onClick={() => setView("month")}
className={cn(
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
view === "month"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
)}
>
Month
</button>
<button
type="button"
onClick={() => setView("week")}
className={cn(
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
view === "week"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
)}
>
Week
</button>
</div>
</div>
</div>
<div className="w-full flex-1 overflow-hidden">
{view === "month" ? (
<Calendar
mode="single"
selected={date}
onSelect={handleSelectDate}
month={viewDate}
onMonthChange={setViewDate}
className="w-full rounded-md border-0 p-0"
classNames={{
root: "w-full p-0",
months: "flex flex-col w-full",
month: "flex flex-col w-full space-y-4",
// Grid - Revert to Flex but Enforce 1/7th Width
// table: "w-full border-collapse", // No table-fixed
head_row: "flex w-full",
row: "flex w-full mt-2",
// Cells & Headers: Explicit width 14.28%
// Use calc(100%/7) via tailwind arbitrary or just flex bases.
// Better: w-[14.28%] flex-none (approx 1/7)
weekdays: "flex w-full border-b",
weekday:
"w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
week: "flex w-full mt-2",
cell: "w-[14.285%] flex-none h-20 sm:h-28 md:h-32 border-b p-0 relative focus-within:relative focus-within:z-20 text-center text-sm",
// Hide internal navigation & caption entirely
nav: "hidden",
caption: "hidden",
day: cn(
"w-full h-full p-2 font-normal aria-selected:opacity-100 flex flex-col items-start justify-start gap-1 hover:bg-accent/50 hover:text-accent-foreground align-top transition-colors rounded-xl",
),
day_selected: "bg-primary/5 text-primary",
day_today: "bg-accent/20",
day_outside: "text-muted-foreground opacity-30",
}}
formatters={{
formatMonthCaption: () => "", // Clear default caption text to prevent duplication
}}
components={{
DayButton: (props) => {
const { day, modifiers, className, ...buttonProps } = props;
const DayDate = day.date;
const dayItems = getItemsForDate(DayDate);
// const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0); // Unused now
return (
<button
{...buttonProps}
type="button"
className={cn(
"hover:border-border/50 hover:bg-secondary/30 relative flex h-full w-full flex-col items-start justify-between overflow-hidden rounded-xl border border-transparent p-2 text-left transition-all",
// Selected State: Filled Box, No Outline
modifiers.selected &&
"bg-primary text-primary-foreground hover:bg-primary/90 scale-[0.98] transform shadow-md",
modifiers.today &&
!modifiers.selected &&
"bg-accent/40 rounded-xl",
className,
)}
>
<span className="z-10 text-sm font-medium">
{DayDate.getDate()}
</span>
{dayItems.length > 0 && (
<div className="mt-1 flex h-full w-full flex-col justify-end gap-1 overflow-hidden pb-1">
<div className="mt-1 flex w-full flex-col gap-1">
{dayItems.slice(0, 4).map((item, idx) => (
<div
key={idx}
className={cn(
"h-1 w-full rounded-full",
modifiers.selected
? "bg-primary-foreground/50"
: "bg-primary/50",
)}
/>
))}
{dayItems.length > 4 && (
<div
className={cn(
"h-1 w-1/3 rounded-full",
modifiers.selected
? "bg-primary-foreground/30"
: "bg-muted-foreground/30",
)}
/>
)}
</div>
</div>
)}
</button>
);
},
}}
/>
) : (
<div className="flex w-full gap-3 overflow-x-auto p-4 pb-6">
{weekDays.map((day) => {
const isSelected = date && isSameDay(day, date);
const isToday = isSameDay(day, new Date());
const dayItems = getItemsForDate(day);
const totalHours = dayItems.reduce(
(acc, curr) => acc + curr.item.hours,
0,
);
return (
<button
key={day.toString()}
type="button"
onClick={() => handleSelectDate(day)}
className={cn(
"hover:bg-accent/30 flex min-h-[260px] w-[120px] flex-shrink-0 flex-col rounded-3xl border p-3 text-left transition-all sm:w-auto sm:flex-1",
isSelected
? "ring-primary bg-primary/5 ring-2 ring-offset-2"
: "bg-background/40",
isToday && !isSelected ? "bg-accent/40" : "",
)}
>
<div className="mb-4 flex w-full flex-col items-center border-b pb-4">
<span className="text-muted-foreground text-xs font-bold uppercase">
{format(day, "EEE")}
</span>
<span className="text-2xl font-light">
{format(day, "d")}
</span>
</div>
<div className="w-full flex-1 space-y-2 overflow-hidden">
{dayItems.length > 0 ? (
dayItems.map(({ item }, i) => (
<div
key={i}
className="bg-background rounded-xl border p-2 text-xs shadow-sm"
>
<div className="line-clamp-2 font-medium text-wrap break-words">
{item.description || "No description"}
</div>
<div className="text-muted-foreground whitespace-nowrap">
{item.hours}h
</div>
</div>
))
) : (
<div className="text-muted-foreground/20 flex h-full items-center justify-center">
<Plus className="h-8 w-8" />
</div>
)}
</div>
{dayItems.length > 0 && (
<div className="mt-auto w-full pt-2 text-center">
<span className="text-sm font-semibold">
{totalHours}h Total
</span>
</div>
)}
</button>
);
})}
</div>
)}
</div>
{/* Sheet for Day Details */}
<Sheet open={sheetOpen} onOpenChange={handleCloseSheet}>
<SheetContent
side="right"
className="flex w-full max-w-full flex-col gap-0 p-0 sm:w-[400px] sm:max-w-[540px]"
>
<SheetHeader className="border-b p-6">
<SheetTitle className="flex flex-wrap items-center gap-3 text-2xl">
<div className="bg-primary/10 flex-shrink-0 rounded-full p-2.5">
<CalendarIcon className="text-primary h-6 w-6" />
</div>
<span className="text-left break-words">
{date ? format(date, "EEEE, MMMM do") : "Details"}
</span>
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-6">
{date && selectedDateItems.length === 0 ? (
<div className="bg-secondary/20 border-border/60 flex flex-col items-center justify-center space-y-4 rounded-3xl border border-dashed py-16 text-center">
<div className="bg-background rounded-full p-4 shadow-sm">
<Clock className="text-muted-foreground/50 h-8 w-8" />
</div>
<div className="space-y-1">
<p className="text-foreground text-lg font-semibold">
No hours logged
</p>
<p className="text-muted-foreground/80 max-w-[200px] text-sm">
There are no time entries recorded for this day yet.
</p>
</div>
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
<Plus className="mr-2 h-4 w-4" />
Log Time
</Button>
</div>
) : (
<div className="space-y-4">
{selectedDateItems.map(({ item, index }) => (
<div
key={item.id}
className="border-border bg-card group hover:border-primary/50 overflow-hidden rounded-lg border transition-colors"
>
<div className="space-y-3 p-4">
{/* Description */}
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">
Description
</Label>
<Input
value={item.description}
onChange={(e) =>
onUpdateItem(index, "description", e.target.value)
}
placeholder="Describe the work performed..."
className="pl-3 text-sm"
/>
</div>
{/* Hours and Rate in a row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">
Hours
</Label>
<NumberInput
value={item.hours}
onChange={(v) => onUpdateItem(index, "hours", v)}
step={0.25}
min={0}
width="full"
/>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">
Rate
</Label>
<NumberInput
value={item.rate}
onChange={(v) => onUpdateItem(index, "rate", v)}
prefix="$"
min={0}
step={1}
width="full"
/>
</div>
</div>
</div>
{/* Bottom section with controls, item name, and total */}
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemoveItem(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 px-3 text-center">
<span className="text-muted-foreground block text-sm font-medium">
Item #{index + 1}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs">
Total
</span>
<span className="text-primary text-lg font-bold">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
</div>
</div>
))}
<Button
variant="outline"
onClick={handleAddNewItem}
className="hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary group w-full gap-2 rounded-xl border-dashed py-8 transition-all"
>
<div className="bg-muted group-hover:bg-primary/10 rounded-md p-1 transition-colors">
<Plus className="h-4 w-4" />
</div>
<span>Add Another Entry</span>
</Button>
</div>
)}
</div>
</div>
<SheetFooter className="bg-muted/10 mt-auto border-t p-6">
<Button
className="h-12 w-full rounded-xl text-base shadow-md sm:w-full"
size="lg"
onClick={() => handleCloseSheet(false)}
>
Done
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
);
} }
+308 -82
View File
@@ -15,13 +15,24 @@ 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, FileText, ChevronDown } from "lucide-react"; import {
Save,
Calendar as CalendarIcon,
Tag,
User,
List,
FileText,
ChevronDown,
Mail,
} from "lucide-react";
import { SUPPORTED_CURRENCIES } from "~/lib/currency"; import { SUPPORTED_CURRENCIES } from "~/lib/currency";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { import {
@@ -49,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"
@@ -65,19 +76,34 @@ function InvoiceFormSkeleton() {
); );
} }
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { function getDefaultHourlyRate(value: unknown) {
const router = useRouter(); if (typeof value !== "object" || value === null) return null;
const utils = api.useUtils();
// State const rate = (value as { defaultHourlyRate?: unknown }).defaultHourlyRate;
const [formData, setFormData] = useState<InvoiceFormData>({ return typeof rate === "number" ? rate : null;
}
function plainTextToHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\n/g, "<br>");
}
function createDefaultInvoiceFormData(): InvoiceFormData {
return {
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`, invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
invoicePrefix: "#",
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", currency: "USD",
defaultHourlyRate: null, defaultHourlyRate: null,
@@ -91,17 +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: 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 } =
@@ -120,32 +159,32 @@ 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, })) || [];
})) // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
.sort(
(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", currency: existingInvoice.currency ?? "USD",
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null, defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
@@ -186,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) => {
@@ -231,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) => {
@@ -333,25 +395,23 @@ 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,
currency: formData.currency, currency: formData.currency,
items: formData.items items: formData.items.map((i) => ({
.sort( date: i.date,
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), description: i.description,
) hours: i.hours,
.map((i) => ({ rate: i.rate,
date: i.date, amount: i.hours * i.rate,
description: i.description, })),
hours: i.hours,
rate: i.rate,
amount: i.hours * i.rate,
})),
}; };
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined) if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
await updateInvoice.mutateAsync({ id: invoiceId, ...payload }); await updateInvoice.mutateAsync({ id: invoiceId, ...payload });
@@ -382,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"
@@ -405,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"
@@ -424,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 */}
@@ -431,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
@@ -448,18 +514,20 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const currentBusiness = businesses?.find( const currentBusiness = businesses?.find(
(b) => b.id === formData.businessId, (b) => b.id === formData.businessId,
); );
const clientRate = const clientRate = getDefaultHourlyRate(selectedClient);
selectedClient && "defaultHourlyRate" in selectedClient
? selectedClient.defaultHourlyRate
: null;
const businessRate = const businessRate =
currentBusiness && "defaultHourlyRate" in currentBusiness getDefaultHourlyRate(currentBusiness);
? currentBusiness.defaultHourlyRate updateField(
: null; "defaultHourlyRate",
updateField("defaultHourlyRate", (clientRate ?? businessRate ?? 0) as number); clientRate ?? businessRate ?? 0,
);
// Auto-fill currency from client // Auto-fill currency from client
if (selectedClient && "currency" in selectedClient && selectedClient.currency) { if (
updateField("currency", selectedClient.currency as string); selectedClient &&
"currency" in selectedClient &&
selectedClient.currency
) {
updateField("currency", selectedClient.currency);
} }
}} }}
> >
@@ -496,10 +564,10 @@ 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">
@@ -525,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>
@@ -589,17 +681,38 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardContent> </CardContent>
</Card> </Card>
{/* Notes card — spans both columns */} <Card className="h-fit">
<Card className="h-fit lg:col-span-2">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-base"> <CardTitle className="flex items-center justify-between gap-2 text-base">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<FileText className="h-4 w-4" /> Notes <Mail className="h-4 w-4" /> Email Message
</span>
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={formData.emailMessage}
onChange={(e) => updateField("emailMessage", e.target.value)}
placeholder="Add a note that appears only in the email body..."
className="min-h-[140px]"
/>
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-base">
<span className="flex items-center gap-2">
<FileText className="h-4 w-4" /> Invoice Notes
</span> </span>
{noteTemplates && noteTemplates.length > 0 && ( {noteTemplates && noteTemplates.length > 0 && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs"> <Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
>
Use template <ChevronDown className="h-3 w-3" /> Use template <ChevronDown className="h-3 w-3" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -621,8 +734,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Textarea <Textarea
value={formData.notes} value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)} onChange={(e) => updateField("notes", e.target.value)}
placeholder="Add notes, payment terms, or other information for the client…" placeholder="Add notes, payment terms, or other information for the invoice/PDF..."
className="min-h-[100px]" className="min-h-[140px]"
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -674,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>
@@ -704,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>
+66 -169
View File
@@ -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,130 +41,67 @@ 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 size="sm"
type="button" className="w-full"
variant="ghost" inputClassName="h-9"
size="sm" />
onClick={() => onMoveUp(index)}
className="h-6 w-6 p-0"
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 */} <Input
<div className="flex-1 space-y-3"> value={item.description}
{/* Description */} onChange={(e) => onUpdate(index, "description", e.target.value)}
<div> placeholder="Describe the work performed..."
<Input className="h-9 w-full text-sm font-medium"
value={item.description} />
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="w-full text-sm font-medium"
/>
</div>
{/* Controls Row */} <NumberInput
<div className="flex flex-wrap items-center gap-3"> value={item.hours}
{/* Date */} onChange={(value) => onUpdate(index, "hours", value)}
<DatePicker min={0}
date={item.date} step={0.25}
onDateChange={(date) => width="full"
onUpdate(index, "date", date ?? new Date()) className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12"
} suffix="h"
size="sm" />
className="w-full sm:w-[180px]"
inputClassName="h-9"
/>
{/* Hours */} <NumberInput
<NumberInput value={item.rate}
value={item.hours} onChange={(value) => onUpdate(index, "rate", value)}
onChange={(value) => onUpdate(index, "hours", value)} min={0}
min={0} step={1}
step={0.25} 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"
suffix="h" />
/>
{/* Rate */} <div className="text-primary text-right font-mono font-semibold">
<NumberInput ${(item.hours * item.rate).toFixed(2)}
value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)}
min={0}
step={1}
prefix="$"
width="auto"
className="h-9 flex-1 min-w-[100px] font-mono"
/>
{/* Amount */}
<div className="ml-auto">
<span className="text-primary font-semibold">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
{/* Actions */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div> </div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-4 w-4" />
</Button>
</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"> <Button
<div className="border-t pt-6"> type="button"
<Button variant="outline"
type="button" onClick={onAddItem}
variant="outline" 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"
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" <Plus className="mr-2 h-4 w-4" />
> Add Line Item
<Plus className="mr-2 h-4 w-4" /> </Button>
Add Line Item
</Button>
</div>
</div>
</div> </div>
); );
} }
@@ -7,196 +7,212 @@ import { Textarea } from "~/components/ui/textarea";
import { DatePicker } from "~/components/ui/date-picker"; import { DatePicker } from "~/components/ui/date-picker";
import { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { import { STATUS_OPTIONS } from "./types";
STATUS_OPTIONS, import type { InvoiceFormData, ClientType, BusinessType } from "./types";
} from "./types";
import type {
InvoiceFormData,
ClientType,
BusinessType,
} from "./types";
interface InvoiceMetaSidebarProps { interface InvoiceMetaSidebarProps {
formData: InvoiceFormData; formData: InvoiceFormData;
updateField: <K extends keyof InvoiceFormData>( updateField: <K extends keyof InvoiceFormData>(
field: K, field: K,
value: InvoiceFormData[K] value: InvoiceFormData[K],
) => void; ) => void;
clients: ClientType[] | undefined; clients: ClientType[] | undefined;
businesses: BusinessType[] | undefined; businesses: BusinessType[] | undefined;
className?: string; className?: string;
} }
export function InvoiceMetaSidebar({ export function InvoiceMetaSidebar({
formData, formData,
updateField, updateField,
clients, clients,
businesses, businesses,
className, className,
}: InvoiceMetaSidebarProps) { }: InvoiceMetaSidebarProps) {
return ( return (
<div className={cn("flex flex-col gap-6 p-4 h-full", className)}> <div className={cn("flex h-full flex-col gap-6 p-4", className)}>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider"> <h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Invoice Details Invoice Details
</h3> </h3>
{/* Status */} {/* Status */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="status" className="text-xs">Status</Label> <Label htmlFor="status" className="text-xs">
<Select Status
value={formData.status} </Label>
onValueChange={(value: "draft" | "sent" | "paid") => <Select
updateField("status", value) value={formData.status}
} onValueChange={(value: "draft" | "sent" | "paid") =>
> updateField("status", value)
<SelectTrigger className="bg-background/50"> }
<SelectValue /> >
</SelectTrigger> <SelectTrigger className="bg-background/50">
<SelectContent> <SelectValue />
{STATUS_OPTIONS.map((option) => ( </SelectTrigger>
<SelectItem key={option.value} value={option.value}> <SelectContent>
{option.label} {STATUS_OPTIONS.map((option) => (
</SelectItem> <SelectItem key={option.value} value={option.value}>
))} {option.label}
</SelectContent> </SelectItem>
</Select> ))}
</div> </SelectContent>
</Select>
{/* Invoice Number */}
<div className="space-y-1.5">
<Label htmlFor="invoiceNumber" className="text-xs">Invoice Number</Label>
<Input
id="invoiceNumber"
value={formData.invoiceNumber}
placeholder="INV-..."
disabled
className="bg-muted/50 font-mono text-sm"
/>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
Involved Parties
</h3>
{/* From (Business) */}
<div className="space-y-1.5">
<Label htmlFor="business" className="text-xs">From (Business)</Label>
<Select
value={formData.businessId}
onValueChange={(value) => updateField("businessId", value)}
>
<SelectTrigger aria-label="From Business" className="bg-background/50 text-sm">
<span className="truncate">
<SelectValue placeholder="Select business" />
</span>
</SelectTrigger>
<SelectContent>
{businesses?.map((business) => (
<SelectItem key={business.id} value={business.id}>
{business.name}{business.nickname ? ` (${business.nickname})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Bill To (Client) */}
<div className="space-y-1.5">
<Label htmlFor="client" className="text-xs">Bill To (Client)</Label>
<Select
value={formData.clientId}
onValueChange={(value) => updateField("clientId", value)}
>
<SelectTrigger aria-label="Bill To Client" className="bg-background/50 text-sm">
<span className="truncate">
<SelectValue placeholder="Select client" />
</span>
</SelectTrigger>
<SelectContent>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
Dates
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Issued</Label>
<DatePicker
date={formData.issueDate}
onDateChange={(date) => updateField("issueDate", date ?? new Date())}
className="w-full bg-background/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Due</Label>
<DatePicker
date={formData.dueDate}
onDateChange={(date) => updateField("dueDate", date ?? new Date())}
className="w-full bg-background/50"
/>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">
Config
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Tax Rate</Label>
<NumberInput
value={formData.taxRate}
onChange={(v) => updateField("taxRate", v)}
min={0}
max={100}
step={1}
suffix="%"
className="bg-background/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Hourly Rate</Label>
<NumberInput
value={formData.defaultHourlyRate ?? 0}
onChange={(v) => updateField("defaultHourlyRate", v)}
min={0}
prefix="$"
placeholder={!formData.clientId ? "Select client" : "Rate"}
disabled={!formData.clientId}
className={cn("bg-background/50", !formData.clientId && "opacity-50")}
/>
</div>
</div>
</div>
<div className="space-y-1.5 flex-1">
<Label className="text-xs">Notes</Label>
<Textarea
value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)}
placeholder="Notes for client..."
className="bg-background/50 resize-none h-24"
/>
</div>
</div> </div>
);
{/* Invoice Number */}
<div className="space-y-1.5">
<Label htmlFor="invoiceNumber" className="text-xs">
Invoice Number
</Label>
<Input
id="invoiceNumber"
value={formData.invoiceNumber}
placeholder="INV-..."
disabled
className="bg-muted/50 font-mono text-sm"
/>
</div>
</div>
<div className="space-y-4">
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Involved Parties
</h3>
{/* From (Business) */}
<div className="space-y-1.5">
<Label htmlFor="business" className="text-xs">
From (Business)
</Label>
<Select
value={formData.businessId}
onValueChange={(value) => updateField("businessId", value)}
>
<SelectTrigger
aria-label="From Business"
className="bg-background/50 text-sm"
>
<span className="truncate">
<SelectValue placeholder="Select business" />
</span>
</SelectTrigger>
<SelectContent>
{businesses?.map((business) => (
<SelectItem key={business.id} value={business.id}>
{business.name}
{business.nickname ? ` (${business.nickname})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Bill To (Client) */}
<div className="space-y-1.5">
<Label htmlFor="client" className="text-xs">
Bill To (Client)
</Label>
<Select
value={formData.clientId}
onValueChange={(value) => updateField("clientId", value)}
>
<SelectTrigger
aria-label="Bill To Client"
className="bg-background/50 text-sm"
>
<span className="truncate">
<SelectValue placeholder="Select client" />
</span>
</SelectTrigger>
<SelectContent>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Dates
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Issued</Label>
<DatePicker
date={formData.issueDate}
onDateChange={(date) =>
updateField("issueDate", date ?? new Date())
}
className="bg-background/50 w-full"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Due</Label>
<DatePicker
date={formData.dueDate}
onDateChange={(date) =>
updateField("dueDate", date ?? new Date())
}
className="bg-background/50 w-full"
/>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Config
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Tax Rate</Label>
<NumberInput
value={formData.taxRate}
onChange={(v) => updateField("taxRate", v)}
min={0}
max={100}
step={1}
suffix="%"
className="bg-background/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Hourly Rate</Label>
<NumberInput
value={formData.defaultHourlyRate ?? 0}
onChange={(v) => updateField("defaultHourlyRate", v)}
min={0}
prefix="$"
placeholder={!formData.clientId ? "Select client" : "Rate"}
disabled={!formData.clientId}
className={cn(
"bg-background/50",
!formData.clientId && "opacity-50",
)}
/>
</div>
</div>
</div>
<div className="flex-1 space-y-1.5">
<Label className="text-xs">Notes</Label>
<Textarea
value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)}
placeholder="Notes for client..."
className="bg-background/50 h-24 resize-none"
/>
</div>
</div>
);
} }
@@ -9,98 +9,93 @@ import { InvoiceCalendarView } from "../invoice-calendar-view";
import type { InvoiceFormData } from "./types"; import type { InvoiceFormData } from "./types";
interface InvoiceWorkspaceProps { interface InvoiceWorkspaceProps {
formData: InvoiceFormData; formData: InvoiceFormData;
viewMode: "list" | "calendar"; viewMode: "list" | "calendar";
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,
className?: string; ) => void;
className?: string;
} }
export function InvoiceWorkspace({ export function InvoiceWorkspace({
formData, formData,
viewMode, viewMode,
setViewMode, setViewMode,
addItem, addItem,
removeItem, removeItem,
updateItem, updateItem,
moveItemUp, className,
moveItemDown,
reorderItems,
className,
}: InvoiceWorkspaceProps) { }: InvoiceWorkspaceProps) {
return (
return ( <div className={cn("flex h-full flex-col", className)}>
<div className={cn("flex flex-col h-full", className)}> {/* Workspace Header / View Toggle */}
{/* Workspace Header / View Toggle */} <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 justify-between p-4 border-b bg-background/50 backdrop-blur-sm sticky top-0 z-10"> <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-muted-foreground ml-2 text-sm">
<div className="text-sm text-muted-foreground ml-2"> {formData.items.length}{" "}
{formData.items.length} {formData.items.length === 1 ? 'entry' : 'entries'} {formData.items.length === 1 ? "entry" : "entries"}
</div> </div>
</div>
<div className="flex items-center bg-secondary/50 p-1 rounded-lg">
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="h-8 gap-2 text-xs"
>
<List className="w-3.5 h-3.5" />
List
</Button>
<Button
variant={viewMode === 'calendar' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('calendar')}
className="h-8 gap-2 text-xs"
>
<CalendarIcon className="w-3.5 h-3.5" />
Calendar
</Button>
</div>
</div>
{/* Workspace Content */}
<div className="flex-1 overflow-hidden relative">
<div className="absolute inset-0 overflow-y-auto p-6 md:p-8">
{viewMode === 'list' ? (
<div className="max-w-4xl mx-auto space-y-6">
<div className="bg-background/40 backdrop-blur-md rounded-xl border border-white/10 p-1">
<InvoiceLineItems
items={formData.items}
onAddItem={() => addItem()}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
onMoveUp={moveItemUp}
onMoveDown={moveItemDown}
onReorderItems={reorderItems}
className="p-4"
/>
</div>
</div>
) : (
<div className="h-full">
<InvoiceCalendarView
items={formData.items}
onAddItem={addItem}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
defaultHourlyRate={formData.defaultHourlyRate}
className="h-full"
/>
</div>
)}
</div>
</div>
</div> </div>
);
<div className="bg-secondary/50 flex items-center rounded-lg p-1">
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("list")}
className="h-8 gap-2 text-xs"
>
<List className="h-3.5 w-3.5" />
List
</Button>
<Button
variant={viewMode === "calendar" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("calendar")}
className="h-8 gap-2 text-xs"
>
<CalendarIcon className="h-3.5 w-3.5" />
Calendar
</Button>
</div>
</div>
{/* Workspace Content */}
<div className="relative flex-1 overflow-hidden">
<div className="absolute inset-0 overflow-y-auto p-6 md:p-8">
{viewMode === "list" ? (
<div className="mx-auto max-w-4xl space-y-6">
<div className="bg-background/40 rounded-xl border border-white/10 p-1 backdrop-blur-md">
<InvoiceLineItems
items={formData.items}
onAddItem={() => addItem()}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
className="p-4"
/>
</div>
</div>
) : (
<div className="h-full">
<InvoiceCalendarView
items={formData.items}
onAddItem={addItem}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
defaultHourlyRate={formData.defaultHourlyRate}
className="h-full"
/>
</div>
)}
</div>
</div>
</div>
);
} }
+22 -20
View File
@@ -4,30 +4,32 @@ export type ClientType = RouterOutputs["clients"]["getAll"][number];
export type BusinessType = RouterOutputs["businesses"]["getAll"][number]; export type BusinessType = RouterOutputs["businesses"]["getAll"][number];
export interface InvoiceItem { export interface InvoiceItem {
id: string; id: string;
date: Date; date: Date;
description: string; description: string;
hours: number; hours: number;
rate: number; rate: number;
amount: number; amount: number;
} }
export interface InvoiceFormData { export interface InvoiceFormData {
invoiceNumber: string; invoiceNumber: string;
businessId: string; invoicePrefix: string;
clientId: string; businessId: string;
issueDate: Date; clientId: string;
dueDate: Date; issueDate: Date;
status: "draft" | "sent" | "paid"; dueDate: Date;
notes: string; status: "draft" | "sent" | "paid";
taxRate: number; notes: string;
currency: string; emailMessage: string;
defaultHourlyRate: number | null; taxRate: number;
items: InvoiceItem[]; currency: string;
defaultHourlyRate: number | null;
items: InvoiceItem[];
} }
export const STATUS_OPTIONS = [ export const STATUS_OPTIONS = [
{ value: "draft", label: "Draft" }, { value: "draft", label: "Draft" },
{ value: "sent", label: "Sent" }, { value: "sent", label: "Sent" },
{ value: "paid", label: "Paid" }, { value: "paid", label: "Paid" },
] as const; ] as const;
-451
View File
@@ -1,451 +0,0 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button";
import { EmailComposer } from "./email-composer";
import { EmailPreview } from "./email-preview";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import {
Send,
Loader2,
Eye,
Edit3,
CheckCircle,
AlertTriangle,
Mail,
} from "lucide-react";
import { Alert, AlertDescription } from "~/components/ui/alert";
interface SendEmailDialogProps {
invoiceId: string;
trigger: React.ReactNode;
invoice?: {
id: string;
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
status: string;
taxRate: number;
client?: {
name: string;
email: string | null;
};
business?: {
name: string;
email: string | null;
};
items?: Array<{
id: string;
hours: number;
rate: number;
}>;
};
onEmailSent?: () => void;
}
export function SendEmailDialog({
invoiceId,
trigger,
invoice,
onEmailSent,
}: SendEmailDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState("compose");
const [isSending, setIsSending] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
// Email content state
const [subject, setSubject] = useState(() =>
invoice
? `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`
: "Invoice from Your Business",
);
const [ccEmail, setCcEmail] = useState("");
const [bccEmail, setBccEmail] = useState("");
const [customMessage, setCustomMessage] = useState("");
const [emailContent, setEmailContent] = useState(() => {
const getTimeOfDayGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
};
if (!invoice) return "";
const businessName = invoice.business?.name ?? "Your Business";
const issueDate = formatDate(invoice.issueDate);
// Calculate total from items
const subtotal =
invoice.items?.reduce((sum, item) => sum + item.hours * item.rate, 0) ??
0;
const taxAmount = subtotal * (invoice.taxRate / 100);
const total = subtotal + taxAmount;
return `<p>${getTimeOfDayGreeting()},</p>
<p>I hope this email finds you well. Please find attached invoice <strong>${invoice.invoiceNumber}</strong> dated ${issueDate}.</p>
<p>The invoice details are as follows:</p>
<ul>
<li><strong>Invoice Number:</strong> ${invoice.invoiceNumber}</li>
<li><strong>Issue Date:</strong> ${issueDate}</li>
<li><strong>Amount Due:</strong> ${new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)}</li>
</ul>
<p>Please let me know if you have any questions or need any clarification regarding this invoice. I appreciate your prompt attention to this matter.</p>
<p>Thank you for your business!</p>
<p>Best regards,<br><strong>${businessName}</strong></p>`;
});
// Get utils for cache invalidation
const utils = api.useUtils();
// Email sending mutation
const sendEmailMutation = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
toast.success("Email sent successfully!", {
description: data.message,
duration: 5000,
});
// Reset state and close dialog
setIsOpen(false);
setActiveTab("compose");
setIsSending(false);
setIsConfirming(false);
// Refresh invoice data
void utils.invoices.getById.invalidate({ id: invoiceId });
// Callback for parent component
onEmailSent?.();
},
onError: (error) => {
console.error("Email send error:", error);
let errorMessage = "Failed to send invoice email";
let errorDescription = error.message;
if (error.message.includes("Invalid recipient")) {
errorMessage = "Invalid Email Address";
errorDescription =
"Please check the client's email address and try again.";
} else if (error.message.includes("domain not verified")) {
errorMessage = "Email Configuration Issue";
errorDescription = "Please contact support to configure email sending.";
} else if (error.message.includes("rate limit")) {
errorMessage = "Too Many Emails";
errorDescription = "Please wait a moment before sending another email.";
} else if (error.message.includes("no email address")) {
errorMessage = "No Email Address";
errorDescription = "This client doesn't have an email address on file.";
}
toast.error(errorMessage, {
description: errorDescription,
duration: 6000,
});
setIsSending(false);
setIsConfirming(false);
},
});
const handleSendEmail = async () => {
if (!invoice?.client?.email || invoice.client.email.trim() === "") {
toast.error("No email address", {
description: "This client doesn't have an email address on file.",
});
return;
}
if (!subject.trim()) {
toast.error("Subject required", {
description: "Please enter an email subject before sending.",
});
return;
}
if (!emailContent.trim()) {
toast.error("Message required", {
description: "Please enter an email message before sending.",
});
return;
}
setIsSending(true);
try {
// Use the enhanced API with custom subject and content
await sendEmailMutation.mutateAsync({
invoiceId,
customSubject: subject,
customContent: emailContent,
customMessage: customMessage.trim() || undefined,
useHtml: true,
ccEmails: ccEmail.trim() || undefined,
bccEmails: bccEmail.trim() || undefined,
});
} catch (error) {
// Error handling is done in the mutation's onError
console.error("Send email error:", error);
}
};
const handleConfirmSend = () => {
setIsConfirming(true);
setActiveTab("confirm");
};
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com";
const toEmail = invoice?.client?.email ?? "";
const canSend =
!isSending &&
subject.trim() &&
emailContent.trim() &&
toEmail &&
toEmail.trim() !== "";
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="text-primary h-5 w-5" />
Send Invoice Email
</DialogTitle>
<DialogDescription>
Compose and preview your invoice email before sending to{" "}
{invoice?.client?.name ?? "client"}.
</DialogDescription>
</DialogHeader>
{/* Warning for missing email */}
{(!toEmail || toEmail.trim() === "") && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This client doesn&apos;t have an email address. Please add an
email address to the client before sending the invoice.
</AlertDescription>
</Alert>
)}
{/* Branded Template Info */}
<Alert>
<Mail className="h-4 w-4" />
<AlertDescription>
<strong>Professional Email Template:</strong> Your email will be
sent using a beautifully designed, beenvoice-branded template with
proper fonts and styling. Any custom content you add will be
incorporated into the professional template automatically.
</AlertDescription>
</Alert>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="min-h-0 flex-1"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="compose" className="flex items-center gap-2">
<Edit3 className="h-4 w-4" />
Compose
</TabsTrigger>
<TabsTrigger value="preview" className="flex items-center gap-2">
<Eye className="h-4 w-4" />
Preview
</TabsTrigger>
<TabsTrigger
value="confirm"
className="flex items-center gap-2"
disabled={!isConfirming}
>
<CheckCircle className="h-4 w-4" />
Confirm
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent
value="compose"
className="mt-4 h-full overflow-y-auto"
>
<EmailComposer
subject={subject}
onSubjectChange={setSubject}
content={emailContent}
onContentChange={setEmailContent}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
onCcEmailChange={setCcEmail}
bccEmail={bccEmail}
onBccEmailChange={setBccEmail}
/>
</TabsContent>
<TabsContent
value="preview"
className="mt-4 h-full overflow-y-auto"
>
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
bccEmail={bccEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
className="pr-2"
/>
</TabsContent>
<TabsContent
value="confirm"
className="mt-4 h-full overflow-y-auto"
>
<div className="space-y-6 pr-2">
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
You&apos;re about to send this email to{" "}
<strong>{toEmail}</strong>. The invoice PDF will be
automatically attached.
</AlertDescription>
</Alert>
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
/>
{invoice?.status === "draft" && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This invoice is currently in <strong>draft</strong>{" "}
status. Sending it will automatically change the status to{" "}
<strong>sent</strong>.
</AlertDescription>
</Alert>
)}
</div>
</TabsContent>
</div>
</Tabs>
<DialogFooter className="flex items-center justify-between">
<div className="flex items-center gap-2">
{activeTab === "compose" && (
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
disabled={isSending}
>
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
)}
{activeTab === "preview" && (
<>
<Button
variant="outline"
onClick={() => setActiveTab("compose")}
disabled={isSending}
>
<Edit3 className="mr-2 h-4 w-4" />
Edit
</Button>
<Button
onClick={handleConfirmSend}
disabled={!canSend}
variant="default"
>
<CheckCircle className="mr-2 h-4 w-4" />
Review & Send
</Button>
</>
)}
{activeTab === "confirm" && (
<>
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
disabled={isSending}
>
Back to Preview
</Button>
<Button
onClick={handleSendEmail}
disabled={!canSend || isSending}
className="bg-primary hover:bg-primary/90"
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Send Email
</>
)}
</Button>
</>
)}
</div>
<Button
variant="ghost"
onClick={() => setIsOpen(false)}
disabled={isSending}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+69 -58
View File
@@ -2,76 +2,87 @@
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 [isMobileOpen, setIsMobileOpen] = React.useState(false); const { sidebarStyle } = useAppearance();
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
<Menu className="h-5 w-5" /> variant="outline"
<span className="sr-only">Toggle menu</span> size="icon"
</Button> className="bg-background h-10 w-10 shadow-sm"
</SheetTrigger> suppressHydrationWarning
{/* Mobile Link / Logo */}
<div className="ml-4 flex items-center gap-2">
<Logo size="sm" />
</div>
<SheetContent side="left" className="p-0 w-72">
<div className="sr-only">
<h2 id="mobile-nav-title">Navigation Menu</h2>
</div>
<Sidebar mobile onClose={() => setIsMobileOpen(false)} />
</SheetContent>
</Sheet>
</div>
{/* Main Content */}
<main
suppressHydrationWarning
className={cn(
"flex-1 min-h-screen min-w-0 transition-all duration-300 ease-in-out",
// Desktop margins based on collapsed state
"md:ml-0",
// Sidebar is fixed at left: 1rem (16px), width: 16rem (256px) or 4rem (64px)
// We need margin-left = left + width + gap
// Expanded: 16px + 256px + 16px (gap) = 288px (18rem)
// Collapsed: 16px + 64px + 16px (gap) = 96px (6rem)
isCollapsed ? "md:ml-24" : "md:ml-[18rem]"
)}
> >
<div className="p-4 pt-16 md:pt-4"> <Menu className="h-5 w-5" />
{/* Mobile header spacer is handled by pt-16 on mobile */} <span className="sr-only">Toggle menu</span>
<div className="md:hidden mb-4"> </Button>
{/* Mobile Breadcrumbs could go here or be part of the page */} </SheetTrigger>
</div> {/* Mobile Link / Logo */}
{children} <div className="ml-4 flex items-center gap-2">
</div> <Logo size="sm" />
</main> </div>
<SheetContent side="left" className="w-72 p-0">
<div className="sr-only">
<h2 id="mobile-nav-title">Navigation Menu</h2>
</div>
<Sidebar mobile onClose={() => setIsMobileOpen(false)} />
</SheetContent>
</Sheet>
</div>
{/* Main Content */}
<main
suppressHydrationWarning
className={cn(
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out",
"md:ml-0",
sidebarStyle === "floating"
? isCollapsed
? "md:ml-24"
: "md:ml-[18rem]"
: isCollapsed
? "md:ml-16"
: "md:ml-64",
)}
>
<div className="dashboard-content-shell p-4 pt-16 md:pt-4">
{/* Mobile header spacer is handled by pt-16 on mobile */}
<div className="mb-4 md:hidden">
{/* Mobile Breadcrumbs could go here or be part of the page */}
</div>
{children}
</div> </div>
); </main>
</div>
);
} }
export function DashboardShell({ children }: { children: React.ReactNode }) { export function DashboardShell({ children }: { children: React.ReactNode }) {
return ( return (
<SidebarProvider> <SidebarProvider>
<DashboardContent>{children}</DashboardContent> <DashboardContent>{children}</DashboardContent>
</SidebarProvider> </SidebarProvider>
); );
} }
+13 -47
View File
@@ -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 flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm: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>
+21 -21
View File
@@ -3,25 +3,25 @@
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
export function MotionBackground() { export function MotionBackground() {
return ( return (
<div className="fixed inset-0 -z-50 overflow-hidden pointer-events-none bg-background"> <div className="bg-background pointer-events-none fixed inset-0 -z-50 overflow-hidden">
<div <div
className={cn( className={cn(
"absolute inset-[-50%] w-[200%] h-[200%]", "absolute inset-[-50%] h-[200%] w-[200%]",
"bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]", "bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]",
"from-[oklch(var(--primary)/0.15)] via-transparent to-transparent", "from-[oklch(var(--primary)/0.15)] via-transparent to-transparent",
"animate-subtle-spin opacity-100" "animate-subtle-spin opacity-100",
)} )}
/> />
<div <div
className={cn( className={cn(
"absolute inset-[-50%] w-[200%] h-[200%]", "absolute inset-[-50%] h-[200%] w-[200%]",
"bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]", "bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]",
"from-[oklch(var(--accent)/0.15)] via-transparent to-transparent", "from-[oklch(var(--accent)/0.15)] via-transparent to-transparent",
"animate-subtle-wave opacity-100" "animate-subtle-wave opacity-100",
)} )}
/> />
<div className="absolute inset-0 bg-[url('/noise.svg')] opacity-[0.02] mix-blend-overlay" /> <div className="absolute inset-0 bg-[url('/noise.svg')] opacity-[0.02] mix-blend-overlay" />
</div> </div>
); );
} }
+16 -10
View File
@@ -8,7 +8,11 @@ import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export function Navbar() { interface NavbarProps {
allowRegistration?: boolean;
}
export function Navbar({ allowRegistration = true }: NavbarProps) {
const { data: session, isPending } = authClient.useSession(); const { data: session, isPending } = authClient.useSession();
// const session = { user: null } as any; const isPending = false; // const session = { user: null } as any; const isPending = false;
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false); const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
@@ -63,15 +67,17 @@ export function Navbar() {
Sign In Sign In
</Button> </Button>
</Link> </Link>
<Link href="/auth/register"> {allowRegistration && (
<Button <Link href="/auth/register">
size="sm" <Button
variant="default" size="sm"
className="text-xs font-medium md:text-sm" variant="default"
> className="text-xs font-medium md:text-sm"
Register >
</Button> Register
</Link> </Button>
</Link>
)}
</> </>
)} )}
</div> </div>
+10 -8
View File
@@ -42,22 +42,24 @@ export function PageHeader({
return ( return (
<div className={`animate-fade-in-down mb-6 ${className}`}> <div className={`animate-fade-in-down mb-6 ${className}`}>
{variant === "large-gradient" || variant === "gradient" ? ( {variant === "large-gradient" || variant === "gradient" ? (
<div className="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>
)} )}
+19 -28
View File
@@ -7,11 +7,7 @@ interface PageLayoutProps {
} }
export function PageLayout({ children, className }: PageLayoutProps) { export function PageLayout({ children, className }: PageLayoutProps) {
return ( return <div className={cn("min-h-screen", className)}>{children}</div>;
<div className={cn("min-h-screen", className)}>
{children}
</div>
);
} }
interface PageContentProps { interface PageContentProps {
@@ -23,18 +19,16 @@ interface PageContentProps {
export function PageContent({ export function PageContent({
children, children,
className, className,
spacing = "default" spacing = "default",
}: PageContentProps) { }: PageContentProps) {
const spacingClasses = { const spacingClasses = {
default: "space-y-8", default: "space-y-8",
compact: "space-y-4", compact: "space-y-4",
large: "space-y-12" large: "space-y-12",
}; };
return ( return (
<div className={cn(spacingClasses[spacing], className)}> <div className={cn(spacingClasses[spacing], className)}>{children}</div>
{children}
</div>
); );
} }
@@ -51,7 +45,7 @@ export function PageSection({
className, className,
title, title,
description, description,
actions actions,
}: PageSectionProps) { }: PageSectionProps) {
return ( return (
<section className={cn("space-y-4", className)}> <section className={cn("space-y-4", className)}>
@@ -59,15 +53,15 @@ export function PageSection({
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div> <div>
{title && ( {title && (
<h2 className="text-xl font-semibold text-foreground">{title}</h2> <h2 className="text-foreground text-xl font-semibold">{title}</h2>
)} )}
{description && ( {description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p> <p className="text-muted-foreground mt-1 text-sm">
{description}
</p>
)} )}
</div> </div>
{actions && ( {actions && <div className="flex flex-shrink-0 gap-3">{actions}</div>}
<div className="flex flex-shrink-0 gap-3">{actions}</div>
)}
</div> </div>
)} )}
{children} {children}
@@ -86,28 +80,25 @@ export function PageGrid({
children, children,
className, className,
columns = 3, columns = 3,
gap = "default" gap = "default",
}: PageGridProps) { }: PageGridProps) {
const columnClasses = { const columnClasses = {
1: "grid-cols-1", 1: "grid-cols-1",
2: "grid-cols-1 md:grid-cols-2", 2: "grid-cols-1 md:grid-cols-2",
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3", 3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4" 4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
}; };
const gapClasses = { const gapClasses = {
default: "gap-4", default: "gap-4",
compact: "gap-2", compact: "gap-2",
large: "gap-6" large: "gap-6",
}; };
return ( return (
<div className={cn( <div
"grid", className={cn("grid", columnClasses[columns], gapClasses[gap], className)}
columnClasses[columns], >
gapClasses[gap],
className
)}>
{children} {children}
</div> </div>
); );
@@ -127,18 +118,18 @@ export function EmptyState({
title, title,
description, description,
action, action,
className className,
}: EmptyStateProps) { }: EmptyStateProps) {
return ( return (
<div className={cn("py-12 text-center", className)}> <div className={cn("py-12 text-center", className)}>
{icon && ( {icon && (
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center bg-muted/50"> <div className="bg-muted/50 mx-auto mb-4 flex h-16 w-16 items-center justify-center">
{icon} {icon}
</div> </div>
)} )}
<h3 className="mb-2 text-lg font-semibold">{title}</h3> <h3 className="mb-2 text-lg font-semibold">{title}</h3>
{description && ( {description && (
<p className="text-muted-foreground mb-4 max-w-sm mx-auto"> <p className="text-muted-foreground mx-auto mb-4 max-w-sm">
{description} {description}
</p> </p>
)} )}
+2 -2
View File
@@ -56,7 +56,7 @@ export function QuickActionCard({
<CardContent className="p-6 text-center"> <CardContent className="p-6 text-center">
<div <div
className={cn( className={cn(
"mx-auto mb-3 flex h-12 w-12 items-center justify-center transition-colors", "mx-auto mb-3 flex h-12 w-12 items-center justify-center transition-colors",
styles.background, styles.background,
styles.hoverBackground, styles.hoverBackground,
)} )}
@@ -101,7 +101,7 @@ export function QuickActionCardSkeleton() {
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="animate-pulse"> <div className="animate-pulse">
<div className="bg-muted mx-auto mb-3 h-12 w-12 "></div> <div className="bg-muted mx-auto mb-3 h-12 w-12"></div>
<div className="bg-muted mx-auto mb-2 h-4 w-2/3 rounded"></div> <div className="bg-muted mx-auto mb-2 h-4 w-2/3 rounded"></div>
<div className="bg-muted mx-auto h-3 w-1/2 rounded"></div> <div className="bg-muted mx-auto h-3 w-1/2 rounded"></div>
</div> </div>
+37 -41
View File
@@ -3,58 +3,54 @@
import * as React from "react"; import * as React from "react";
interface SidebarContextType { interface SidebarContextType {
isCollapsed: boolean; isCollapsed: boolean;
toggleCollapse: () => void; toggleCollapse: () => void;
expand: () => void; expand: () => void;
collapse: () => void; collapse: () => void;
} }
const SidebarContext = React.createContext<SidebarContextType | undefined>( const SidebarContext = React.createContext<SidebarContextType | undefined>(
undefined, undefined,
); );
export function SidebarProvider({ children }: { children: React.ReactNode }) { export function SidebarProvider({ children }: { children: React.ReactNode }) {
const [isCollapsed, setIsCollapsed] = React.useState(false); const [isCollapsed, setIsCollapsed] = React.useState(() => {
if (typeof window === "undefined") return false;
const saved = localStorage.getItem("sidebar-collapsed");
return saved ? (JSON.parse(saved) as boolean) : false;
});
// Persist state if needed, for now just local state const toggleCollapse = React.useCallback(() => {
React.useEffect(() => { setIsCollapsed((prev) => {
const saved = localStorage.getItem("sidebar-collapsed"); const next = !prev;
if (saved) { localStorage.setItem("sidebar-collapsed", JSON.stringify(next));
setIsCollapsed(JSON.parse(saved) as boolean); return next;
} });
}, []); }, []);
const toggleCollapse = React.useCallback(() => { const expand = React.useCallback(() => {
setIsCollapsed((prev) => { setIsCollapsed(false);
const next = !prev; localStorage.setItem("sidebar-collapsed", JSON.stringify(false));
localStorage.setItem("sidebar-collapsed", JSON.stringify(next)); }, []);
return next;
});
}, []);
const expand = React.useCallback(() => { const collapse = React.useCallback(() => {
setIsCollapsed(false); setIsCollapsed(true);
localStorage.setItem("sidebar-collapsed", JSON.stringify(false)); localStorage.setItem("sidebar-collapsed", JSON.stringify(true));
}, []); }, []);
const collapse = React.useCallback(() => { return (
setIsCollapsed(true); <SidebarContext.Provider
localStorage.setItem("sidebar-collapsed", JSON.stringify(true)); value={{ isCollapsed, toggleCollapse, expand, collapse }}
}, []); >
{children}
return ( </SidebarContext.Provider>
<SidebarContext.Provider );
value={{ isCollapsed, toggleCollapse, expand, collapse }}
>
{children}
</SidebarContext.Provider>
);
} }
export function useSidebar() { export function useSidebar() {
const context = React.useContext(SidebarContext); const context = React.useContext(SidebarContext);
if (context === undefined) { if (context === undefined) {
throw new Error("useSidebar must be used within a SidebarProvider"); throw new Error("useSidebar must be used within a SidebarProvider");
} }
return context; return context;
} }
+100 -47
View File
@@ -5,16 +5,17 @@ import { usePathname } from "next/navigation";
import { authClient } from "~/lib/auth-client"; import { authClient } from "~/lib/auth-client";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
LogOut,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import { navigationConfig } from "~/lib/navigation"; import { navigationConfig } from "~/lib/navigation";
import { useSidebar } from "./sidebar-provider"; import { useSidebar } from "./sidebar-provider";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Logo } from "~/components/branding/logo"; import { Logo } from "~/components/branding/logo";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip"; import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -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}
+6 -3
View File
@@ -14,12 +14,15 @@ export function Breadcrumbs() {
})), })),
]; ];
return ( return (
<nav className="flex items-center text-sm text-muted-foreground" aria-label="Breadcrumb"> <nav
className="text-muted-foreground flex items-center text-sm"
aria-label="Breadcrumb"
>
{crumbs.map((crumb, i) => ( {crumbs.map((crumb, i) => (
<span key={crumb.href} className="flex items-center"> <span key={crumb.href} className="flex items-center">
{i > 0 && <ChevronRight className="mx-2 h-4 w-4 text-gray-300" />} {i > 0 && <ChevronRight className="mx-2 h-4 w-4 text-gray-300" />}
{i < crumbs.length - 1 ? ( {i < crumbs.length - 1 ? (
<Link href={crumb.href} className="hover:underline text-gray-500"> <Link href={crumb.href} className="text-gray-500 hover:underline">
{crumb.name} {crumb.name}
</Link> </Link>
) : ( ) : (
@@ -29,4 +32,4 @@ export function Breadcrumbs() {
))} ))}
</nav> </nav>
); );
} }
@@ -53,7 +53,7 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
(_, i) => ( (_, i) => (
<div <div
key={i} key={i}
className="flex items-center gap-3 px-3 py-2.5" className="flex items-center gap-3 px-3 py-2.5"
> >
<Skeleton className="bg-muted/20 h-4 w-4" /> <Skeleton className="bg-muted/20 h-4 w-4" />
<Skeleton className="bg-muted/20 h-4 w-20" /> <Skeleton className="bg-muted/20 h-4 w-20" />
@@ -71,10 +71,11 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
aria-current={ aria-current={
pathname === link.href ? "page" : undefined pathname === link.href ? "page" : undefined
} }
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${pathname === link.href className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${
? "bg-primary/10 text-primary" pathname === link.href
: "text-foreground hover:bg-muted" ? "bg-primary/10 text-primary"
}`} : "text-foreground hover:bg-muted"
}`}
onClick={onToggle} onClick={onToggle}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
@@ -205,9 +205,9 @@ export function AnimationPreferencesProvider({
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const stored = readLocalStorage(); const stored = readLocalStorage();
const systemReduced = const systemReduced = window.matchMedia?.(
window.matchMedia && "(prefers-reduced-motion: reduce)",
window.matchMedia("(prefers-reduced-motion: reduce)").matches; ).matches;
const finalPrefers = const finalPrefers =
stored?.prefersReducedMotion ?? stored?.prefersReducedMotion ??
@@ -216,10 +216,11 @@ export function AnimationPreferencesProvider({
DEFAULT_PREFERS_REDUCED; DEFAULT_PREFERS_REDUCED;
const finalSpeed = clampSpeed( const finalSpeed = clampSpeed(
stored?.animationSpeedMultiplier ?? stored?.animationSpeedMultiplier ??
initial?.animationSpeedMultiplier ?? initial?.animationSpeedMultiplier ??
DEFAULT_SPEED, DEFAULT_SPEED,
); );
// eslint-disable-next-line react-hooks/set-state-in-effect -- Hydrate preferences from localStorage/system settings on mount.
setPrefersReducedMotion(finalPrefers); setPrefersReducedMotion(finalPrefers);
setAnimationSpeedMultiplier(finalSpeed); setAnimationSpeedMultiplier(finalSpeed);
applyPreferencesToDOM({ applyPreferencesToDOM({
@@ -279,7 +280,8 @@ export function AnimationPreferencesProvider({
// Optionally sync to server // Optionally sync to server
const shouldSync = opts?.sync ?? autoSync; const shouldSync = opts?.sync ?? autoSync;
if (shouldSync && serverPrefs) { // If serverPrefs exists, user is authenticated if (shouldSync && serverPrefs) {
// If serverPrefs exists, user is authenticated
pendingSyncRef.current = { pendingSyncRef.current = {
prefersReducedMotion: patch.prefersReducedMotion, prefersReducedMotion: patch.prefersReducedMotion,
animationSpeedMultiplier: patch.animationSpeedMultiplier, animationSpeedMultiplier: patch.animationSpeedMultiplier,
@@ -334,6 +336,7 @@ export function AnimationPreferencesProvider({
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier; serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) { if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reconcile loaded server preferences once after query hydration.
performUpdate( performUpdate(
{ {
prefersReducedMotion: serverPrefs.prefersReducedMotion, prefersReducedMotion: serverPrefs.prefersReducedMotion,
@@ -402,9 +405,15 @@ export function useAnimationPreferences(): AnimationPreferencesContextValue {
return { return {
prefersReducedMotion: false, prefersReducedMotion: false,
animationSpeedMultiplier: 1, animationSpeedMultiplier: 1,
updatePreferences: () => { /* no-op fallback */ }, updatePreferences: () => {
setPrefersReducedMotion: () => { /* no-op fallback */ }, /* no-op fallback */
setAnimationSpeedMultiplier: () => { /* no-op fallback */ }, },
setPrefersReducedMotion: () => {
/* no-op fallback */
},
setAnimationSpeedMultiplier: () => {
/* no-op fallback */
},
isUpdating: false, isUpdating: false,
lastSyncedAt: null, lastSyncedAt: null,
}; };
@@ -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;
}
+21 -21
View File
@@ -1,15 +1,15 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
import { buttonVariants } from "~/components/ui/button" import { buttonVariants } from "~/components/ui/button";
function AlertDialog({ function AlertDialog({
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} /> return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
} }
function AlertDialogTrigger({ function AlertDialogTrigger({
@@ -17,7 +17,7 @@ function AlertDialogTrigger({
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return ( return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} /> <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
) );
} }
function AlertDialogPortal({ function AlertDialogPortal({
@@ -25,7 +25,7 @@ function AlertDialogPortal({
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return ( return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} /> <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
) );
} }
function AlertDialogOverlay({ function AlertDialogOverlay({
@@ -37,11 +37,11 @@ function AlertDialogOverlay({
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogContent({ function AlertDialogContent({
@@ -54,13 +54,13 @@ function AlertDialogContent({
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
className className,
)} )}
{...props} {...props}
/> />
</AlertDialogPortal> </AlertDialogPortal>
) );
} }
function AlertDialogHeader({ function AlertDialogHeader({
@@ -73,7 +73,7 @@ function AlertDialogHeader({
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogFooter({ function AlertDialogFooter({
@@ -85,11 +85,11 @@ function AlertDialogFooter({
data-slot="alert-dialog-footer" data-slot="alert-dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogTitle({ function AlertDialogTitle({
@@ -102,7 +102,7 @@ function AlertDialogTitle({
className={cn("text-lg font-semibold", className)} className={cn("text-lg font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogDescription({ function AlertDialogDescription({
@@ -115,7 +115,7 @@ function AlertDialogDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogAction({ function AlertDialogAction({
@@ -127,7 +127,7 @@ function AlertDialogAction({
className={cn(buttonVariants(), className)} className={cn(buttonVariants(), className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogCancel({ function AlertDialogCancel({
@@ -139,7 +139,7 @@ function AlertDialogCancel({
className={cn(buttonVariants({ variant: "outline" }), className)} className={cn(buttonVariants({ variant: "outline" }), className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -154,4 +154,4 @@ export {
AlertDialogDescription, AlertDialogDescription,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
} };
+38 -38
View File
@@ -1,50 +1,50 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar" import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
const Avatar = React.forwardRef< const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>, React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AvatarPrimitive.Root <AvatarPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className className,
)} )}
{...props} {...props}
/> />
)) ));
Avatar.displayName = AvatarPrimitive.Root.displayName Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef< const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>, React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AvatarPrimitive.Image <AvatarPrimitive.Image
ref={ref} ref={ref}
className={cn("aspect-square h-full w-full", className)} className={cn("aspect-square h-full w-full", className)}
{...props} {...props}
/> />
)) ));
AvatarImage.displayName = AvatarPrimitive.Image.displayName AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef< const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>, React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted", "bg-muted flex h-full w-full items-center justify-center rounded-full",
className className,
)} )}
{...props} {...props}
/> />
)) ));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback } export { Avatar, AvatarImage, AvatarFallback };
+15 -15
View File
@@ -1,11 +1,11 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react" import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} /> return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
} }
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
@@ -14,11 +14,11 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
data-slot="breadcrumb-list" data-slot="breadcrumb-list"
className={cn( className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
@@ -28,7 +28,7 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
className={cn("inline-flex items-center gap-1.5", className)} className={cn("inline-flex items-center gap-1.5", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbLink({ function BreadcrumbLink({
@@ -36,9 +36,9 @@ function BreadcrumbLink({
className, className,
...props ...props
}: React.ComponentProps<"a"> & { }: React.ComponentProps<"a"> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot : "a" const Comp = asChild ? Slot : "a";
return ( return (
<Comp <Comp
@@ -46,7 +46,7 @@ function BreadcrumbLink({
className={cn("hover:text-foreground transition-colors", className)} className={cn("hover:text-foreground transition-colors", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
@@ -59,7 +59,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
className={cn("text-foreground font-normal", className)} className={cn("text-foreground font-normal", className)}
{...props} {...props}
/> />
) );
} }
function BreadcrumbSeparator({ function BreadcrumbSeparator({
@@ -77,7 +77,7 @@ function BreadcrumbSeparator({
> >
{children ?? <ChevronRight />} {children ?? <ChevronRight />}
</li> </li>
) );
} }
function BreadcrumbEllipsis({ function BreadcrumbEllipsis({
@@ -95,7 +95,7 @@ function BreadcrumbEllipsis({
<MoreHorizontal className="size-4" /> <MoreHorizontal className="size-4" />
<span className="sr-only">More</span> <span className="sr-only">More</span>
</span> </span>
) );
} }
export { export {
@@ -106,4 +106,4 @@ export {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
BreadcrumbEllipsis, BreadcrumbEllipsis,
} };
+1 -1
View File
@@ -36,7 +36,7 @@ const buttonVariants = cva(
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean; asChild?: boolean;
} }
+49 -43
View File
@@ -1,15 +1,19 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { import {
ChevronDownIcon, ChevronDownIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
} from "lucide-react" } from "lucide-react";
import { DayPicker, getDefaultClassNames, type DayButton } from "react-day-picker" import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
import { Button, buttonVariants } from "~/components/ui/button" import { Button, buttonVariants } from "~/components/ui/button";
function Calendar({ function Calendar({
className, className,
@@ -21,9 +25,9 @@ function Calendar({
components, components,
...props ...props
}: React.ComponentProps<typeof DayPicker> & { }: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"] buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) { }) {
const defaultClassNames = getDefaultClassNames() const defaultClassNames = getDefaultClassNames();
return ( return (
<DayPicker <DayPicker
@@ -32,7 +36,7 @@ function Calendar({
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent", "bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className className,
)} )}
captionLayout={captionLayout} captionLayout={captionLayout}
formatters={{ formatters={{
@@ -44,86 +48,88 @@ function Calendar({
root: cn("w-fit", defaultClassNames.root), root: cn("w-fit", defaultClassNames.root),
months: cn( months: cn(
"flex gap-4 flex-col md:flex-row relative", "flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months defaultClassNames.months,
), ),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month), month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn( nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav defaultClassNames.nav,
), ),
button_previous: cn( button_previous: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous defaultClassNames.button_previous,
), ),
button_next: cn( button_next: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next defaultClassNames.button_next,
), ),
month_caption: cn( month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption defaultClassNames.month_caption,
), ),
dropdowns: cn( dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns defaultClassNames.dropdowns,
), ),
dropdown_root: cn( dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root defaultClassNames.dropdown_root,
), ),
dropdown: cn( dropdown: cn(
"absolute bg-popover inset-0 opacity-0", "absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown defaultClassNames.dropdown,
), ),
caption_label: cn( caption_label: cn(
"select-none font-medium", "select-none font-medium",
captionLayout === "label" captionLayout === "label"
? "text-sm" ? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label defaultClassNames.caption_label,
), ),
table: "w-full border-collapse", table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays), weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn( weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday defaultClassNames.weekday,
), ),
week: cn("flex w-full mt-2", defaultClassNames.week), week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn( week_number_header: cn(
"select-none w-(--cell-size)", "select-none w-(--cell-size)",
defaultClassNames.week_number_header defaultClassNames.week_number_header,
), ),
week_number: cn( week_number: cn(
"text-[0.8rem] select-none text-muted-foreground", "text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number defaultClassNames.week_number,
), ),
day: cn( day: cn(
"relative w-full h-full p-0 text-center group/day aspect-square select-none", "relative w-full h-full p-0 text-center group/day aspect-square select-none",
props.mode !== "single" && "[&:last-child[data-selected=true]_button]:rounded-r-md", props.mode !== "single" &&
props.mode !== "single" && (props.showWeekNumber "[&:last-child[data-selected=true]_button]:rounded-r-md",
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" props.mode !== "single" &&
: "[&:first-child[data-selected=true]_button]:rounded-l-md"), (props.showWeekNumber
defaultClassNames.day ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md"),
defaultClassNames.day,
), ),
range_start: cn( range_start: cn(
"rounded-l-md bg-accent", "rounded-l-md bg-accent",
defaultClassNames.range_start defaultClassNames.range_start,
), ),
range_middle: cn("rounded-none", defaultClassNames.range_middle), range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn( today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today defaultClassNames.today,
), ),
outside: cn( outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground", "text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside defaultClassNames.outside,
), ),
disabled: cn( disabled: cn(
"text-muted-foreground opacity-50", "text-muted-foreground opacity-50",
defaultClassNames.disabled defaultClassNames.disabled,
), ),
hidden: cn("invisible", defaultClassNames.hidden), hidden: cn("invisible", defaultClassNames.hidden),
...classNames, ...classNames,
@@ -137,13 +143,13 @@ function Calendar({
className={cn(className)} className={cn(className)}
{...props} {...props}
/> />
) );
}, },
Chevron: ({ className, orientation, ...props }) => { Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") { if (orientation === "left") {
return ( return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} /> <ChevronLeftIcon className={cn("size-4", className)} {...props} />
) );
} }
if (orientation === "right") { if (orientation === "right") {
@@ -152,12 +158,12 @@ function Calendar({
className={cn("size-4", className)} className={cn("size-4", className)}
{...props} {...props}
/> />
) );
} }
return ( return (
<ChevronDownIcon className={cn("size-4", className)} {...props} /> <ChevronDownIcon className={cn("size-4", className)} {...props} />
) );
}, },
DayButton: CalendarDayButton, DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => { WeekNumber: ({ children, ...props }) => {
@@ -167,13 +173,13 @@ function Calendar({
{children} {children}
</div> </div>
</td> </td>
) );
}, },
...components, ...components,
}} }}
{...props} {...props}
/> />
) );
} }
function CalendarDayButton({ function CalendarDayButton({
@@ -182,12 +188,12 @@ function CalendarDayButton({
modifiers, modifiers,
...props ...props
}: React.ComponentProps<typeof DayButton>) { }: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames() const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null) const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (modifiers.focused) ref.current?.focus() if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]) }, [modifiers.focused]);
return ( return (
<Button <Button
@@ -207,11 +213,11 @@ function CalendarDayButton({
className={cn( className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70", "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day, defaultClassNames.day,
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Calendar, CalendarDayButton } export { Calendar, CalendarDayButton };
+1 -1
View File
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-background/80 backdrop-blur-xl border-border/50 text-card-foreground flex flex-col rounded-3xl border shadow-sm overflow-hidden", "bg-background/80 border-border/50 text-card-foreground flex flex-col overflow-hidden rounded-3xl border shadow-sm backdrop-blur-xl",
className, className,
)} )}
{...props} {...props}
+8 -8
View File
@@ -1,10 +1,10 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react" import { CheckIcon } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Checkbox({ function Checkbox({
className, className,
@@ -15,7 +15,7 @@ function Checkbox({
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
{...props} {...props}
> >
@@ -26,7 +26,7 @@ function Checkbox({
<CheckIcon className="size-3.5" /> <CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) );
} }
export { Checkbox } export { Checkbox };
+6 -6
View File
@@ -1,11 +1,11 @@
"use client" "use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({ function Collapsible({
...props ...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) { }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} /> return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
} }
function CollapsibleTrigger({ function CollapsibleTrigger({
@@ -16,7 +16,7 @@ function CollapsibleTrigger({
data-slot="collapsible-trigger" data-slot="collapsible-trigger"
{...props} {...props}
/> />
) );
} }
function CollapsibleContent({ function CollapsibleContent({
@@ -27,7 +27,7 @@ function CollapsibleContent({
data-slot="collapsible-content" data-slot="collapsible-content"
{...props} {...props}
/> />
) );
} }
export { Collapsible, CollapsibleTrigger, CollapsibleContent } export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+18 -7
View File
@@ -3,13 +3,24 @@
import { motion, useSpring, useTransform } from "framer-motion"; import { motion, useSpring, useTransform } from "framer-motion";
import { useEffect } from "react"; import { useEffect } from "react";
export function CountUp({ value, prefix = "", suffix = "" }: { value: number, prefix?: string, suffix?: string }) { export function CountUp({
const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 }); value,
const display = useTransform(spring, (current) => `${prefix}${current.toFixed(2)}${suffix}`); prefix = "",
suffix = "",
}: {
value: number;
prefix?: string;
suffix?: string;
}) {
const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 });
const display = useTransform(
spring,
(current) => `${prefix}${current.toFixed(2)}${suffix}`,
);
useEffect(() => { useEffect(() => {
spring.set(value); spring.set(value);
}, [spring, value]); }, [spring, value]);
return <motion.span>{display}</motion.span>; return <motion.span>{display}</motion.span>;
} }
+14 -5
View File
@@ -60,12 +60,13 @@ export function DatePicker({
const inputWidthClass = className?.includes("w-full") const inputWidthClass = className?.includes("w-full")
? "w-full" ? "w-full"
: className?.includes("w-32") || : className?.includes("w-32") ||
className?.includes("w-28") || className?.includes("w-28") ||
className?.includes("w-36") className?.includes("w-36")
? className ? className
: "w-full md:w-32 md:min-w-32"; : "w-full md:w-32 md:min-w-32";
React.useEffect(() => { React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
setValue(formatDate(date)); setValue(formatDate(date));
setMonth(date); setMonth(date);
}, [date]); }, [date]);
@@ -77,7 +78,12 @@ export function DatePicker({
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
className={cn("bg-background pr-10", sizeClasses[size], "w-full", inputClassName)} className={cn(
"bg-background pr-10",
sizeClasses[size],
"w-full",
inputClassName,
)}
onChange={(e) => { onChange={(e) => {
setValue(e.target.value); setValue(e.target.value);
const parsedDate = parseDate(e.target.value); const parsedDate = parseDate(e.target.value);
@@ -98,13 +104,16 @@ export function DatePicker({
<Button <Button
variant="ghost" variant="ghost"
disabled={disabled} disabled={disabled}
className="absolute top-1/2 right-2 size-6 p-0 -translate-y-1/2 text-primary/80 hover:text-primary transition-colors z-20" className="text-primary/80 hover:text-primary absolute top-1/2 right-2 z-20 size-6 -translate-y-1/2 p-0 transition-colors"
> >
<CalendarIcon className="size-4" /> <CalendarIcon className="size-4" />
<span className="sr-only">Select date</span> <span className="sr-only">Select date</span>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0 rounded-xl" align="end"> <PopoverContent
className="w-auto overflow-hidden rounded-xl p-0"
align="end"
>
<Calendar <Calendar
mode="single" mode="single"
selected={date} selected={date}
+21 -21
View File
@@ -1,33 +1,33 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
@@ -39,11 +39,11 @@ function DialogOverlay({
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@@ -52,7 +52,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
@@ -60,8 +60,8 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
className className,
)} )}
{...props} {...props}
> >
@@ -77,7 +77,7 @@ function DialogContent({
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -87,7 +87,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -96,11 +96,11 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogTitle({ function DialogTitle({
@@ -113,7 +113,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@@ -126,7 +126,7 @@ function DialogDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -140,4 +140,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };
+6 -6
View File
@@ -42,7 +42,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md",
className, className,
)} )}
{...props} {...props}
@@ -74,7 +74,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-foreground-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-foreground-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
checked={checked} checked={checked}
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-accent focus:text-foreground-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground-foreground flex cursor-default items-center px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8", "focus:bg-accent focus:text-foreground-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground-foreground flex cursor-default items-center px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className, className,
)} )}
{...props} {...props}
@@ -230,7 +230,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden border-0 shadow-lg", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden border-0 shadow-lg",
className, className,
)} )}
{...props} {...props}
+25 -25
View File
@@ -4,34 +4,34 @@ import { cn } from "~/lib/utils";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
interface ImageWithSkeletonProps extends ImageProps { interface ImageWithSkeletonProps extends ImageProps {
containerClassName?: string; containerClassName?: string;
} }
export function ImageWithSkeleton({ export function ImageWithSkeleton({
className, className,
containerClassName, containerClassName,
alt, alt,
...props ...props
}: ImageWithSkeletonProps) { }: ImageWithSkeletonProps) {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
return ( return (
<div className={cn("relative overflow-hidden", containerClassName)}> <div className={cn("relative overflow-hidden", containerClassName)}>
{isLoading && ( {isLoading && (
<Skeleton className="absolute inset-0 h-full w-full animate-pulse" /> <Skeleton className="absolute inset-0 h-full w-full animate-pulse" />
)} )}
<Image <Image
className={cn( className={cn(
"duration-700 ease-in-out", "duration-700 ease-in-out",
isLoading isLoading
? "scale-110 blur-2xl grayscale" ? "scale-110 blur-2xl grayscale"
: "scale-100 blur-0 grayscale-0", : "blur-0 scale-100 grayscale-0",
className className,
)} )}
onLoad={() => setIsLoading(false)} onLoad={() => setIsLoading(false)}
alt={alt} alt={alt}
{...props} {...props}
/> />
</div> </div>
); );
} }
+568
View File
@@ -0,0 +1,568 @@
"use client";
import { useEffect, useState } from "react";
import { HexAlphaColorPicker, HexColorPicker } from "react-colorful";
import { Loader2, PipetteIcon } from "lucide-react";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
hexToRgb,
hexToRgba,
hslToRgb,
hslaToRgba,
rgbToHex,
rgbToHsl,
rgbaToHex,
rgbaToHsla,
} from "~/lib/color-converter";
import { cn } from "~/lib/utils";
declare global {
interface Window {
EyeDropper?: new () => {
open: () => Promise<{ sRGBHex: string }>;
};
}
}
export const colorSchema = z
.string()
.regex(
/^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/,
"Color must be a valid hex color (e.g., #FF0000 or #FF0000FF)",
)
.transform((val) => val.toUpperCase());
interface ColorPickerProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
isLoading?: boolean;
label: string;
error?: string;
className?: string;
alpha?: boolean;
}
interface ColorValues {
hex: string;
rgb: { r: number; g: number; b: number };
hsl: { h: number; s: number; l: number };
rgba?: { r: number; g: number; b: number; a: number };
hsla?: { h: number; s: number; l: number; a: number };
}
export function InputColor({
value,
onChange,
onBlur = () => undefined,
isLoading = false,
label,
error,
className = "mt-6",
alpha = false,
}: ColorPickerProps) {
const [colorFormat, setColorFormat] = useState(alpha ? "HEXA" : "HEX");
const [colorValues, setColorValues] = useState<ColorValues>(() =>
getColorValues(value, alpha),
);
const [hexInputValue, setHexInputValue] = useState(value);
const [hexInputError, setHexInputError] = useState<string | null>(null);
const updateColorValues = (newColor: string) => {
const nextValues = getColorValues(newColor, alpha);
setColorValues(nextValues);
setHexInputValue(newColor.toUpperCase());
};
const handleColorChange = (newColor: string) => {
updateColorValues(newColor);
onChange(newColor.toUpperCase());
};
const handleHexChange = (nextValue: string) => {
let formattedValue = nextValue.toUpperCase();
if (!formattedValue.startsWith("#")) {
formattedValue = `#${formattedValue}`;
}
const maxLength = alpha ? 9 : 7;
if (
formattedValue.length <= maxLength &&
/^#[0-9A-Fa-f]*$/.test(formattedValue)
) {
setHexInputValue(formattedValue);
onChange(formattedValue);
updateColorValues(formattedValue);
try {
if (formattedValue.length === maxLength) {
colorSchema.parse(formattedValue);
setHexInputError(null);
} else {
setHexInputError("Enter a valid color");
}
} catch (validationError) {
if (validationError instanceof z.ZodError) {
setHexInputError("Enter a valid color");
}
}
}
};
const handleRgbChange = (component: "r" | "g" | "b", nextValue: string) => {
const numValue = Number.parseInt(nextValue) || 0;
const clampedValue = Math.max(0, Math.min(255, numValue));
const newRgb = { ...colorValues.rgb, [component]: clampedValue };
const hex = rgbToHex(newRgb.r, newRgb.g, newRgb.b);
const hsl = rgbToHsl(newRgb.r, newRgb.g, newRgb.b);
setColorValues({ ...colorValues, hex, rgb: newRgb, hsl });
setHexInputValue(hex);
onChange(hex);
};
const handleRgbaChange = (
component: "r" | "g" | "b" | "a",
nextValue: string,
) => {
if (!alpha || !colorValues.rgba) return;
const numValue = Number.parseFloat(nextValue) || 0;
const clampedValue =
component === "a"
? Math.max(0, Math.min(1, numValue))
: Math.max(0, Math.min(255, Math.floor(numValue)));
const newRgba = { ...colorValues.rgba, [component]: clampedValue };
const hex = rgbaToHex(newRgba.r, newRgba.g, newRgba.b, newRgba.a);
const hsla = rgbaToHsla(newRgba.r, newRgba.g, newRgba.b, newRgba.a);
setColorValues({
...colorValues,
hex: hex.slice(0, 7),
rgb: { r: newRgba.r, g: newRgba.g, b: newRgba.b },
hsl: rgbToHsl(newRgba.r, newRgba.g, newRgba.b),
rgba: newRgba,
hsla,
});
setHexInputValue(hex);
onChange(hex);
};
const handleHslChange = (component: "h" | "s" | "l", nextValue: string) => {
const numValue = Number.parseInt(nextValue) || 0;
const clampedValue =
component === "h"
? Math.max(0, Math.min(360, numValue))
: Math.max(0, Math.min(100, numValue));
const newHsl = { ...colorValues.hsl, [component]: clampedValue };
const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l);
const hex = rgbToHex(rgb.r, rgb.g, rgb.b);
setColorValues({ ...colorValues, hex, rgb, hsl: newHsl });
setHexInputValue(hex);
onChange(hex);
};
const handleHslaChange = (
component: "h" | "s" | "l" | "a",
nextValue: string,
) => {
if (!alpha || !colorValues.hsla) return;
const numValue = Number.parseFloat(nextValue) || 0;
const clampedValue =
component === "a"
? Math.max(0, Math.min(1, numValue))
: component === "h"
? Math.max(0, Math.min(360, numValue))
: Math.max(0, Math.min(100, numValue));
const newHsla = { ...colorValues.hsla, [component]: clampedValue };
const rgba = hslaToRgba(newHsla.h, newHsla.s, newHsla.l, newHsla.a);
const hex = rgbaToHex(rgba.r, rgba.g, rgba.b, rgba.a);
setColorValues({
...colorValues,
hex: hex.slice(0, 7),
rgb: { r: rgba.r, g: rgba.g, b: rgba.b },
hsl: { h: newHsla.h, s: newHsla.s, l: newHsla.l },
rgba,
hsla: newHsla,
});
setHexInputValue(hex);
onChange(hex);
};
const handlePopoverChange = (open: boolean) => {
if (!open) {
setColorFormat(alpha ? "HEXA" : "HEX");
onBlur();
}
};
const handleEyeDropper = async () => {
const EyeDropper = window.EyeDropper;
if (!EyeDropper) return;
try {
const eyeDropper = new EyeDropper();
const result = await eyeDropper.open();
const pickedColor = result.sRGBHex;
updateColorValues(pickedColor);
onChange(pickedColor);
} catch {
// User canceled the browser picker.
}
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Synchronize controlled color value into the picker fields.
updateColorValues(value);
setHexInputValue(value.toUpperCase());
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateColorValues intentionally derives all picker state from value.
}, [value]);
const getCurrentHexValue = () => {
if (colorFormat === "HEX" || colorFormat === "HEXA") {
return hexInputValue;
}
if (alpha && colorValues.rgba) {
return rgbaToHex(
colorValues.rgba.r,
colorValues.rgba.g,
colorValues.rgba.b,
colorValues.rgba.a,
);
}
return colorValues.hex;
};
return (
<div className={cn(className)}>
<Label className="mb-3">{label}</Label>
<div className="flex items-center gap-4">
<Popover onOpenChange={handlePopoverChange}>
<PopoverTrigger asChild>
<Button
className="border-border relative h-12 w-12 overflow-hidden border shadow-none"
size="icon"
style={{ backgroundColor: hexInputValue }}
type="button"
variant="outline"
>
{alpha && colorValues.rgba && colorValues.rgba.a < 1 && (
<span
className="absolute inset-0 opacity-20"
style={{
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
backgroundSize: "8px 8px",
backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
}}
/>
)}
<span className="sr-only">Open {label} picker</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-3" align="start">
<div className="color-picker space-y-3">
<div className="relative">
<Button
variant="ghost"
size="icon"
className="absolute -top-1.5 -left-1 z-10 flex h-7 w-7 items-center gap-1 bg-transparent hover:bg-transparent"
onClick={handleEyeDropper}
disabled={!isEyeDropperAvailable()}
type="button"
>
<PipetteIcon className="h-3 w-3" />
<span className="sr-only">Pick color from screen</span>
</Button>
{alpha ? (
<HexAlphaColorPicker
className="!aspect-square !h-[244.79px] !w-[244.79px]"
color={value}
onChange={handleColorChange}
/>
) : (
<HexColorPicker
className="!aspect-square !h-[244.79px] !w-[244.79px]"
color={value}
onChange={handleColorChange}
/>
)}
</div>
<div className="flex gap-2">
<Select value={colorFormat} onValueChange={setColorFormat}>
<SelectTrigger className="!h-7 !w-[4.8rem] rounded-sm px-2 py-1 !text-sm">
<SelectValue placeholder="Color" />
</SelectTrigger>
<SelectContent className="min-w-20">
{alpha ? (
<>
<SelectItem value="HEXA" className="h-7 text-sm">
HEXA
</SelectItem>
<SelectItem value="RGBA" className="h-7 text-sm">
RGBA
</SelectItem>
<SelectItem value="HSLA" className="h-7 text-sm">
HSLA
</SelectItem>
</>
) : (
<>
<SelectItem value="HEX" className="h-7 text-sm">
HEX
</SelectItem>
<SelectItem value="RGB" className="h-7 text-sm">
RGB
</SelectItem>
<SelectItem value="HSL" className="h-7 text-sm">
HSL
</SelectItem>
</>
)}
</SelectContent>
</Select>
<ColorFormatFields
alpha={alpha}
colorFormat={colorFormat}
colorValues={colorValues}
currentHexValue={getCurrentHexValue()}
handleHexChange={handleHexChange}
handleHslChange={handleHslChange}
handleHslaChange={handleHslaChange}
handleRgbChange={handleRgbChange}
handleRgbaChange={handleRgbaChange}
/>
</div>
</div>
</PopoverContent>
</Popover>
<div className="relative flex-1 sm:flex-none">
<Input
placeholder={label}
value={getCurrentHexValue()}
onChange={(event) => handleHexChange(event.target.value)}
onBlur={onBlur}
className={cn("h-12 uppercase", error && "border-destructive")}
/>
{isLoading && (
<span className="absolute inset-y-0 right-0 flex items-center pr-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</span>
)}
</div>
</div>
{error && <p className="text-destructive mt-1.5 text-sm">{error}</p>}
{hexInputError && (
<p className="text-destructive mt-1.5 text-sm">{hexInputError}</p>
)}
</div>
);
}
function ColorFormatFields({
alpha,
colorFormat,
colorValues,
currentHexValue,
handleHexChange,
handleRgbChange,
handleRgbaChange,
handleHslChange,
handleHslaChange,
}: {
alpha: boolean;
colorFormat: string;
colorValues: ColorValues;
currentHexValue: string;
handleHexChange: (value: string) => void;
handleRgbChange: (component: "r" | "g" | "b", value: string) => void;
handleRgbaChange: (component: "r" | "g" | "b" | "a", value: string) => void;
handleHslChange: (component: "h" | "s" | "l", value: string) => void;
handleHslaChange: (component: "h" | "s" | "l" | "a", value: string) => void;
}) {
if (colorFormat === "HEX" || colorFormat === "HEXA") {
return (
<Input
className="h-7 w-[160px] rounded-sm text-sm"
value={currentHexValue}
onChange={(event) => handleHexChange(event.target.value)}
placeholder={alpha ? "#FF0000FF" : "#FF0000"}
maxLength={alpha ? 9 : 7}
/>
);
}
if (colorFormat === "RGB") {
return (
<div className="flex items-center">
<Input
className="h-7 w-13 rounded-l-sm rounded-r-none text-center text-sm"
value={colorValues.rgb.r}
onChange={(event) => handleRgbChange("r", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-none border-x-0 text-center text-sm"
value={colorValues.rgb.g}
onChange={(event) => handleRgbChange("g", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-l-none rounded-r-sm text-center text-sm"
value={colorValues.rgb.b}
onChange={(event) => handleRgbChange("b", event.target.value)}
placeholder="255"
maxLength={3}
/>
</div>
);
}
if (colorFormat === "RGBA" && alpha && colorValues.rgba) {
return (
<div className="flex items-center">
<Input
className="h-7 w-10 rounded-l-sm rounded-r-none px-1 text-center text-sm"
value={colorValues.rgba.r}
onChange={(event) => handleRgbaChange("r", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.rgba.g}
onChange={(event) => handleRgbaChange("g", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.rgba.b}
onChange={(event) => handleRgbaChange("b", event.target.value)}
placeholder="255"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-l-none rounded-r-sm px-1 text-center text-sm"
value={colorValues.rgba.a.toFixed(2)}
onChange={(event) => handleRgbaChange("a", event.target.value)}
placeholder="1.00"
maxLength={4}
/>
</div>
);
}
if (colorFormat === "HSL") {
return (
<div className="flex items-center">
<Input
className="h-7 w-13 rounded-l-sm rounded-r-none text-center text-sm"
value={colorValues.hsl.h}
onChange={(event) => handleHslChange("h", event.target.value)}
placeholder="360"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-none border-x-0 text-center text-sm"
value={colorValues.hsl.s}
onChange={(event) => handleHslChange("s", event.target.value)}
placeholder="100"
maxLength={3}
/>
<Input
className="h-7 w-13 rounded-l-none rounded-r-sm text-center text-sm"
value={colorValues.hsl.l}
onChange={(event) => handleHslChange("l", event.target.value)}
placeholder="100"
maxLength={3}
/>
</div>
);
}
if (colorFormat === "HSLA" && alpha && colorValues.hsla) {
return (
<div className="flex items-center">
<Input
className="h-7 w-10 rounded-l-sm rounded-r-none px-1 text-center text-sm"
value={colorValues.hsla.h}
onChange={(event) => handleHslaChange("h", event.target.value)}
placeholder="360"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.hsla.s}
onChange={(event) => handleHslaChange("s", event.target.value)}
placeholder="100"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
value={colorValues.hsla.l}
onChange={(event) => handleHslaChange("l", event.target.value)}
placeholder="100"
maxLength={3}
/>
<Input
className="h-7 w-10 rounded-l-none rounded-r-sm px-1 text-center text-sm"
value={colorValues.hsla.a.toFixed(2)}
onChange={(event) => handleHslaChange("a", event.target.value)}
placeholder="1.00"
maxLength={4}
/>
</div>
);
}
return null;
}
function getColorValues(value: string, alpha: boolean): ColorValues {
if (alpha) {
const rgba = hexToRgba(value);
const hsla = rgbaToHsla(rgba.r, rgba.g, rgba.b, rgba.a);
return {
hex: value.length === 9 ? value.slice(0, 7) : value,
rgb: { r: rgba.r, g: rgba.g, b: rgba.b },
hsl: rgbToHsl(rgba.r, rgba.g, rgba.b),
rgba,
hsla,
};
}
const rgb = hexToRgb(value);
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
return {
hex: value.toUpperCase(),
rgb,
hsl,
};
}
function isEyeDropperAvailable() {
return typeof window !== "undefined" && Boolean(window.EyeDropper);
}
+7 -7
View File
@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Label({ function Label({
className, className,
@@ -14,11 +14,11 @@ function Label({
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };
+27 -27
View File
@@ -1,9 +1,9 @@
import * as React from "react" import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority" import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react" import { ChevronDownIcon } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function NavigationMenu({ function NavigationMenu({
className, className,
@@ -11,7 +11,7 @@ function NavigationMenu({
viewport = true, viewport = true,
...props ...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & { }: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean viewport?: boolean;
}) { }) {
return ( return (
<NavigationMenuPrimitive.Root <NavigationMenuPrimitive.Root
@@ -19,14 +19,14 @@ function NavigationMenu({
data-viewport={viewport} data-viewport={viewport}
className={cn( className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center", "group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
{viewport && <NavigationMenuViewport />} {viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root> </NavigationMenuPrimitive.Root>
) );
} }
function NavigationMenuList({ function NavigationMenuList({
@@ -38,11 +38,11 @@ function NavigationMenuList({
data-slot="navigation-menu-list" data-slot="navigation-menu-list"
className={cn( className={cn(
"group flex flex-1 list-none items-center justify-center gap-1", "group flex flex-1 list-none items-center justify-center gap-1",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function NavigationMenuItem({ function NavigationMenuItem({
@@ -55,12 +55,12 @@ function NavigationMenuItem({
className={cn("relative", className)} className={cn("relative", className)}
{...props} {...props}
/> />
) );
} }
const navigationMenuTriggerStyle = cva( const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-foreground-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1" "group inline-flex h-9 w-max items-center justify-center bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-foreground-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
) );
function NavigationMenuTrigger({ function NavigationMenuTrigger({
className, className,
@@ -79,7 +79,7 @@ function NavigationMenuTrigger({
aria-hidden="true" aria-hidden="true"
/> />
</NavigationMenuPrimitive.Trigger> </NavigationMenuPrimitive.Trigger>
) );
} }
function NavigationMenuContent({ function NavigationMenuContent({
@@ -91,12 +91,12 @@ function NavigationMenuContent({
data-slot="navigation-menu-content" data-slot="navigation-menu-content"
className={cn( className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto", "data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu: group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none", "group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu: group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function NavigationMenuViewport({ function NavigationMenuViewport({
@@ -106,19 +106,19 @@ function NavigationMenuViewport({
return ( return (
<div <div
className={cn( className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center" "absolute top-full left-0 isolate z-50 flex justify-center",
)} )}
> >
<NavigationMenuPrimitive.Viewport <NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport" data-slot="navigation-menu-viewport"
className={cn( className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden border shadow md:w-[var(--radix-navigation-menu-viewport-width)]", "origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className className,
)} )}
{...props} {...props}
/> />
</div> </div>
) );
} }
function NavigationMenuLink({ function NavigationMenuLink({
@@ -129,12 +129,12 @@ function NavigationMenuLink({
<NavigationMenuPrimitive.Link <NavigationMenuPrimitive.Link
data-slot="navigation-menu-link" data-slot="navigation-menu-link"
className={cn( className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-foreground-foreground hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4", "data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-foreground-foreground hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function NavigationMenuIndicator({ function NavigationMenuIndicator({
@@ -146,13 +146,13 @@ function NavigationMenuIndicator({
data-slot="navigation-menu-indicator" data-slot="navigation-menu-indicator"
className={cn( className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden", "data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className className,
)} )}
{...props} {...props}
> >
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" /> <div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator> </NavigationMenuPrimitive.Indicator>
) );
} }
export { export {
@@ -165,4 +165,4 @@ export {
NavigationMenuIndicator, NavigationMenuIndicator,
NavigationMenuViewport, NavigationMenuViewport,
navigationMenuTriggerStyle, navigationMenuTriggerStyle,
} };
+11 -11
View File
@@ -1,20 +1,20 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover" import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Popover({ function Popover({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) { }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} /> return <PopoverPrimitive.Root data-slot="popover" {...props} />;
} }
function PopoverTrigger({ function PopoverTrigger({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
} }
function PopoverContent({ function PopoverContent({
@@ -30,19 +30,19 @@ function PopoverContent({
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) border p-4 shadow-md outline-hidden", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) border p-4 shadow-md outline-hidden",
className className,
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
) );
} }
function PopoverAnchor({ function PopoverAnchor({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
} }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

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