mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 17:48:55 -04:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd3181fb9d | |||
| 915ec103fc | |||
| 4108019eab | |||
| 84a5d997b4 | |||
| ad89ad001d | |||
| 4fd6772f2e | |||
| fbeca7cfee | |||
| b582b6c88e | |||
| 00e066ca4e | |||
| 4214a4b4de | |||
| af392e1bc9 | |||
| 74f9696023 | |||
| 1f76cf38a7 | |||
| e5242b37a4 | |||
| 38206f34fe | |||
| e950abd805 | |||
| 4c0eae4b11 |
+1
-3
@@ -6,14 +6,12 @@ Dockerfile*
|
|||||||
docker-compose*
|
docker-compose*
|
||||||
README.md
|
README.md
|
||||||
*.log
|
*.log
|
||||||
|
.DS_Store
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
drizzle/*.sql
|
|
||||||
drizzle/*-journal
|
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
coverage
|
coverage
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
|
|
||||||
|
|||||||
+43
-35
@@ -1,43 +1,51 @@
|
|||||||
# Base application env
|
# Copy this file to .env before running Docker Compose:
|
||||||
NODE_ENV="development"
|
# cp .env.example .env
|
||||||
PORT="3000"
|
|
||||||
HOSTNAME="0.0.0.0"
|
# Runtime
|
||||||
|
NODE_ENV=production
|
||||||
|
WEB_PORT=3000
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
# You can generate a new secret on the command line with:
|
# Generate with: openssl rand -base64 32
|
||||||
# openssl rand -base64 32
|
AUTH_SECRET=change-me-generate-a-real-secret
|
||||||
AUTH_SECRET="your-auth-secret"
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
BETTER_AUTH_URL="http://localhost:3000" # Set to your production URL in production
|
|
||||||
|
|
||||||
# App URL
|
# Public app URL
|
||||||
# Used for client-side redirects and base URLs
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
|
||||||
|
|
||||||
# Database (Postgres)
|
# Postgres used by docker-compose.yml
|
||||||
# These are required for Docker container initialization
|
POSTGRES_USER=postgres
|
||||||
POSTGRES_USER="postgres"
|
POSTGRES_PASSWORD=postgres
|
||||||
POSTGRES_PASSWORD="postgres"
|
POSTGRES_DB=postgres
|
||||||
POSTGRES_DB="postgres"
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
||||||
|
DB_DISABLE_SSL=true
|
||||||
|
|
||||||
# Connect string for the app
|
# White-label defaults used at image build time.
|
||||||
DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres"
|
# Admin-managed platform branding in the app can override these after setup.
|
||||||
# Disable SSL for Docker local Postgres; set to false or remove for managed Postgres
|
NEXT_PUBLIC_BRAND_NAME="beenvoice"
|
||||||
DB_DISABLE_SSL="true"
|
NEXT_PUBLIC_BRAND_TAGLINE="Simple and efficient invoicing for freelancers and small businesses"
|
||||||
|
NEXT_PUBLIC_BRAND_LOGO_TEXT="beenvoice"
|
||||||
|
NEXT_PUBLIC_BRAND_ICON="$"
|
||||||
|
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME="beenvoice"
|
||||||
|
NEXT_PUBLIC_DEFAULT_FONT="brand"
|
||||||
|
NEXT_PUBLIC_DEFAULT_BODY_FONT="brand"
|
||||||
|
NEXT_PUBLIC_DEFAULT_HEADING_FONT="brand"
|
||||||
|
NEXT_PUBLIC_DEFAULT_RADIUS="xl"
|
||||||
|
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE="floating"
|
||||||
|
|
||||||
# Email (Resend). Replace with real keys in production
|
# Email delivery via Resend (optional)
|
||||||
RESEND_API_KEY="your-resend-api-key"
|
# Leave blank to disable invoice/password-reset email delivery.
|
||||||
RESEND_DOMAIN=""
|
RESEND_API_KEY=
|
||||||
|
RESEND_DOMAIN=
|
||||||
|
|
||||||
# Analytics
|
# Analytics via Umami (optional)
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID="your-website-id-here"
|
# Leave website ID blank to disable analytics.
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL="https://analytics.umami.is/script.js"
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
# Build tweaks
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
|
||||||
# SKIP_ENV_VALIDATION=1
|
|
||||||
|
|
||||||
# SSO / Authentik (Optional - only needed if using SSO authentication)
|
# SSO via Authentik OIDC (optional)
|
||||||
# Configure these if you want to enable Single Sign-On with Authentik OIDC
|
NEXT_PUBLIC_AUTHENTIK_ENABLED=false
|
||||||
# The issuer should be your Authentik application's OAuth2 provider URL
|
AUTHENTIK_ISSUER=
|
||||||
# Example: https://auth.example.com/application/o/your-app-slug
|
AUTHENTIK_CLIENT_ID=
|
||||||
AUTHENTIK_ISSUER=""
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
AUTHENTIK_CLIENT_ID=""
|
AUTHENTIK_ORIGIN=
|
||||||
AUTHENTIK_CLIENT_SECRET=""
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ yarn-error.log*
|
|||||||
# local env files
|
# local env files
|
||||||
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
||||||
.env
|
.env
|
||||||
|
.env.prod
|
||||||
.env*.local
|
.env*.local
|
||||||
.env*.production
|
.env*.production
|
||||||
|
|
||||||
|
|||||||
+26
-49
@@ -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"]
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+33
-9
@@ -1,21 +1,45 @@
|
|||||||
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}
|
||||||
|
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
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "beenvoice_expense" ADD COLUMN "taxDeductible" boolean DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "beenvoice_user" ADD COLUMN "interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL;
|
||||||
|
ALTER TABLE "beenvoice_user" ADD COLUMN "fontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
ALTER TABLE "beenvoice_user"
|
||||||
|
ADD COLUMN "role" varchar(20) DEFAULT 'user' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "beenvoice_user"
|
||||||
|
SET "role" = 'admin'
|
||||||
|
WHERE "id" = (
|
||||||
|
SELECT "id"
|
||||||
|
FROM "beenvoice_user"
|
||||||
|
ORDER BY "createdAt" ASC
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "beenvoice_platform_setting" (
|
||||||
|
"id" varchar(50) PRIMARY KEY DEFAULT 'global' NOT NULL,
|
||||||
|
"brandName" varchar(100) DEFAULT 'beenvoice' NOT NULL,
|
||||||
|
"brandTagline" varchar(255) DEFAULT 'Simple and efficient invoicing for freelancers and small businesses' NOT NULL,
|
||||||
|
"brandLogoText" varchar(100) DEFAULT 'beenvoice' NOT NULL,
|
||||||
|
"brandIcon" varchar(20) DEFAULT '$' NOT NULL,
|
||||||
|
"colorTheme" varchar(50) DEFAULT 'slate' NOT NULL,
|
||||||
|
"customColor" varchar(50),
|
||||||
|
"theme" varchar(20) DEFAULT 'system' NOT NULL,
|
||||||
|
"interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL,
|
||||||
|
"bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
|
||||||
|
"headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
|
||||||
|
"radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL,
|
||||||
|
"sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "beenvoice_platform_setting" (
|
||||||
|
"id",
|
||||||
|
"brandName",
|
||||||
|
"brandTagline",
|
||||||
|
"brandLogoText",
|
||||||
|
"brandIcon",
|
||||||
|
"colorTheme",
|
||||||
|
"customColor",
|
||||||
|
"theme",
|
||||||
|
"interfaceTheme",
|
||||||
|
"bodyFontPreference",
|
||||||
|
"headingFontPreference",
|
||||||
|
"radiusPreference",
|
||||||
|
"sidebarStyle"
|
||||||
|
) VALUES (
|
||||||
|
'global',
|
||||||
|
'beenvoice',
|
||||||
|
'Simple and efficient invoicing for freelancers and small businesses',
|
||||||
|
'beenvoice',
|
||||||
|
'$',
|
||||||
|
'slate',
|
||||||
|
NULL,
|
||||||
|
'system',
|
||||||
|
'beenvoice',
|
||||||
|
'brand',
|
||||||
|
'brand',
|
||||||
|
'xl',
|
||||||
|
'floating'
|
||||||
|
) ON CONFLICT ("id") DO NOTHING;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfTemplate" varchar(20) DEFAULT 'classic' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfAccentColor" varchar(50) DEFAULT '#111827' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfFooterText" varchar(120) DEFAULT 'Professional Invoicing' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfShowLogo" boolean DEFAULT true NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_platform_setting"
|
||||||
|
ADD COLUMN "pdfShowPageNumbers" boolean DEFAULT true NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "beenvoice_invoice"
|
||||||
|
ADD COLUMN "emailMessage" varchar(2000);
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+2
-1
@@ -6,7 +6,8 @@ import "./src/env.js";
|
|||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
serverExternalPackages: ['pg'],
|
output: "standalone",
|
||||||
|
serverExternalPackages: ["pg"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
+4
-4
@@ -7,12 +7,12 @@
|
|||||||
"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 up -d",
|
||||||
"docker:down": "docker-compose down && colima stop",
|
"docker:down": "docker compose down && colima stop",
|
||||||
"deploy": "drizzle-kit push && next build",
|
"deploy": "drizzle-kit push && next build",
|
||||||
"dev": "next dev --turbo",
|
"dev": "next dev --turbo",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
"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",
|
||||||
@@ -92,7 +93,6 @@
|
|||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"baseline-browser-mapping": "^2.9.6",
|
"baseline-browser-mapping": "^2.9.6",
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"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.0.10",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Label } from "~/components/ui/label";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Logo } from "~/components/branding/logo";
|
import { Logo } from "~/components/branding/logo";
|
||||||
import { LegalModal } from "~/components/ui/legal-modal";
|
import { LegalModal } from "~/components/ui/legal-modal";
|
||||||
|
import { env } from "~/env";
|
||||||
import {
|
import {
|
||||||
Mail,
|
Mail,
|
||||||
Lock,
|
Lock,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
function SignInForm() {
|
function SignInForm() {
|
||||||
|
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||||
@@ -63,24 +65,27 @@ function SignInForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center relative overflow-hidden">
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
|
||||||
{/* Blob Background */}
|
{/* Blob Background */}
|
||||||
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
|
<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="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 className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
|
||||||
</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">
|
<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">
|
<CardContent className="grid h-full p-0 md:grid-cols-2">
|
||||||
{/* Hero Section - Hidden on mobile */}
|
{/* 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="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-8">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Logo size="xl" />
|
<Logo size="xl" />
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h1 className="text-3xl font-bold lg:text-4xl font-heading">
|
<h1 className="font-heading text-3xl font-bold lg:text-4xl">
|
||||||
Welcome back to your
|
Welcome back to your
|
||||||
<span className="text-primary italic"> invoicing workspace</span>
|
<span className="text-primary italic">
|
||||||
|
{" "}
|
||||||
|
invoicing workspace
|
||||||
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground text-lg">
|
<p className="text-muted-foreground text-lg">
|
||||||
Continue managing your clients and creating professional
|
Continue managing your clients and creating professional
|
||||||
@@ -95,7 +100,9 @@ function SignInForm() {
|
|||||||
<Users className="text-primary h-5 w-5" />
|
<Users className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="font-semibold text-foreground">Client Management</h3>
|
<h3 className="text-foreground font-semibold">
|
||||||
|
Client Management
|
||||||
|
</h3>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Organize and track all your clients in one place
|
Organize and track all your clients in one place
|
||||||
</p>
|
</p>
|
||||||
@@ -107,7 +114,9 @@ function SignInForm() {
|
|||||||
<FileText className="text-primary h-5 w-5" />
|
<FileText className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="font-semibold text-foreground">Professional Invoices</h3>
|
<h3 className="text-foreground font-semibold">
|
||||||
|
Professional Invoices
|
||||||
|
</h3>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Beautiful templates that get you paid faster
|
Beautiful templates that get you paid faster
|
||||||
</p>
|
</p>
|
||||||
@@ -119,7 +128,9 @@ function SignInForm() {
|
|||||||
<TrendingUp className="text-primary h-5 w-5" />
|
<TrendingUp className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="font-semibold text-foreground">Payment Tracking</h3>
|
<h3 className="text-foreground font-semibold">
|
||||||
|
Payment Tracking
|
||||||
|
</h3>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Monitor your income with real-time insights
|
Monitor your income with real-time insights
|
||||||
</p>
|
</p>
|
||||||
@@ -138,35 +149,37 @@ function SignInForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 text-center md:text-left">
|
<div className="space-y-2 text-center md:text-left">
|
||||||
<h1 className="text-3xl font-bold font-heading">Sign In</h1>
|
<h1 className="font-heading text-3xl font-bold">Sign In</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Enter your credentials to access your account
|
Enter your credentials to access your account
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{authentikEnabled && (
|
||||||
<Button
|
<div className="space-y-4">
|
||||||
variant="outline"
|
<Button
|
||||||
type="button"
|
variant="outline"
|
||||||
className="w-full h-11 relative rounded-xl"
|
type="button"
|
||||||
onClick={handleSocialSignIn}
|
className="relative h-11 w-full rounded-xl"
|
||||||
disabled={loading}
|
onClick={handleSocialSignIn}
|
||||||
>
|
disabled={loading}
|
||||||
<Shield className="mr-2 h-4 w-4" />
|
>
|
||||||
Sign in with Authentik
|
<Shield className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
Sign in with Authentik
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<span className="w-full border-t border-border/50" />
|
<span className="border-border/50 w-full border-t" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
<span className="bg-background px-2 text-muted-foreground">
|
<span className="bg-background text-muted-foreground px-2">
|
||||||
Or continue with
|
Or continue with
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSignIn} className="space-y-4">
|
<form onSubmit={handleSignIn} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -180,7 +193,7 @@ function SignInForm() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-11 pl-10 bg-background/50 border-border/60 focus:bg-background transition-all"
|
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
|
||||||
placeholder="m@example.com"
|
placeholder="m@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,7 +217,7 @@ function SignInForm() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
className="h-11 pl-10 bg-background/50 border-border/60 focus:bg-background transition-all"
|
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,7 +225,7 @@ function SignInForm() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="h-11 w-full rounded-xl text-base shadow-lg shadow-primary/20 hover:shadow-primary/30"
|
className="shadow-primary/20 hover:shadow-primary/30 h-11 w-full rounded-xl text-base shadow-lg"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -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: "",
|
||||||
};
|
};
|
||||||
@@ -89,6 +91,7 @@ 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 ?? "",
|
||||||
});
|
});
|
||||||
@@ -97,13 +100,14 @@ export default function ExpensesPage() {
|
|||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!form.description.trim()) { toast.error("Description is required"); return; }
|
if (!form.description.trim()) { toast.error("Description is required"); return; }
|
||||||
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); 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 };
|
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">
|
||||||
@@ -114,7 +118,7 @@ export default function ExpensesPage() {
|
|||||||
</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 uppercase tracking-wide">Total</p>
|
||||||
@@ -127,7 +131,13 @@ export default function ExpensesPage() {
|
|||||||
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</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">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Deductible</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-green-600">{formatCurrency(deductibleTotal)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
|
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
|
||||||
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
|
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
|
||||||
@@ -159,6 +169,7 @@ export default function ExpensesPage() {
|
|||||||
<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 && <Badge variant="secondary" className="text-xs">Billable</Badge>}
|
||||||
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>}
|
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>}
|
||||||
|
{expense.taxDeductible && <Badge variant="outline" className="text-xs text-green-600 border-green-300">Tax Deductible</Badge>}
|
||||||
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</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">
|
||||||
@@ -229,7 +240,7 @@ export default function ExpensesPage() {
|
|||||||
</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>
|
||||||
@@ -238,6 +249,10 @@ export default function ExpensesPage() {
|
|||||||
<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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -99,10 +99,10 @@ 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -411,7 +411,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="sticky top-20">
|
<Card className="lg:sticky lg:top-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Check className="h-5 w-5" />
|
<Check className="h-5 w-5" />
|
||||||
|
|||||||
@@ -54,6 +54,32 @@ function SendEmailPageSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function plainTextToHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmailNoteHtml(value: string) {
|
||||||
|
const visibleText = value
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/p>/gi, "\n")
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/ |\u00a0/g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return visibleText ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
export default function SendEmailPage() {
|
export default function SendEmailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -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,
|
||||||
@@ -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>
|
||||||
|
|||||||
+756
-170
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+63
-13
@@ -6,26 +6,34 @@ import { Inter, Playfair_Display, Geist_Mono } from "next/font/google";
|
|||||||
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,
|
||||||
|
defaultFontPreference,
|
||||||
|
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 inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-sans",
|
variable: "--font-inter",
|
||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
const playfair = Playfair_Display({
|
const playfair = Playfair_Display({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-heading",
|
variable: "--font-playfair",
|
||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,20 +50,62 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
lang="en"
|
lang="en"
|
||||||
|
data-interface-theme={defaultInterfaceTheme}
|
||||||
|
data-font={defaultFontPreference}
|
||||||
|
data-body-font={defaultBodyFontPreference}
|
||||||
|
data-heading-font={defaultHeadingFontPreference}
|
||||||
|
data-radius={defaultRadiusPreference}
|
||||||
|
data-sidebar-style={defaultSidebarStyle}
|
||||||
|
data-color-mode="system"
|
||||||
|
data-color-theme="slate"
|
||||||
className={`${inter.variable} ${playfair.variable} ${geistMono.variable}`}
|
className={`${inter.variable} ${playfair.variable} ${geistMono.variable}`}
|
||||||
>
|
>
|
||||||
|
<head>
|
||||||
|
<script
|
||||||
|
id="appearance-init"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
try {
|
||||||
|
var defaults = {
|
||||||
|
interfaceTheme: "${defaultInterfaceTheme}",
|
||||||
|
fontPreference: "${defaultFontPreference}",
|
||||||
|
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.font = appearance.fontPreference;
|
||||||
|
root.dataset.bodyFont = appearance.bodyFontPreference || appearance.fontPreference;
|
||||||
|
root.dataset.headingFont = appearance.headingFontPreference || appearance.fontPreference;
|
||||||
|
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>
|
||||||
|
|||||||
+85
-49
@@ -12,20 +12,21 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
Rocket,
|
Rocket,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { brand } from "~/lib/branding";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen relative overflow-x-hidden">
|
<div className="relative min-h-screen overflow-x-hidden">
|
||||||
<AuthRedirect />
|
<AuthRedirect />
|
||||||
|
|
||||||
{/* Blob Background for Homepage */}
|
{/* Blob Background for Homepage */}
|
||||||
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
|
<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="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 className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="fixed top-4 left-4 right-4 z-50 m-4 rounded-2xl border border-border/60 bg-background/80 backdrop-blur-md">
|
<nav className="border-border/60 bg-background/80 fixed top-4 right-4 left-4 z-50 m-4 rounded-2xl border backdrop-blur-md">
|
||||||
<div className="mx-auto px-6">
|
<div className="mx-auto px-6">
|
||||||
<div className="flex h-16 items-center justify-between">
|
<div className="flex h-16 items-center justify-between">
|
||||||
<Logo />
|
<Logo />
|
||||||
@@ -67,25 +68,25 @@ export default function HomePage() {
|
|||||||
<section className="relative pt-48 pb-32">
|
<section className="relative pt-48 pb-32">
|
||||||
<div className="container mx-auto px-4 text-center">
|
<div className="container mx-auto px-4 text-center">
|
||||||
<div className="mx-auto max-w-4xl">
|
<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">
|
<Badge className="bg-primary/10 text-primary border-primary/20 mb-8 rounded-full border px-4 py-1 text-sm">
|
||||||
<Zap className="mr-2 h-3.5 w-3.5" />
|
<Zap className="mr-2 h-3.5 w-3.5" />
|
||||||
Completely Free for Everyone
|
Completely Free for Everyone
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<h1 className="text-foreground mb-8 text-6xl font-heading font-bold tracking-tight sm:text-7xl lg:text-8xl leading-tight">
|
<h1 className="text-foreground font-heading mb-8 text-6xl leading-tight font-bold tracking-tight sm:text-7xl lg:text-8xl">
|
||||||
Invoicing Made <br />
|
{brand.name} <br />
|
||||||
<span className="text-primary italic">Beautifully Simple.</span>
|
<span className="text-primary italic">Beautifully Simple.</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-muted-foreground mx-auto mb-12 max-w-2xl text-xl leading-relaxed font-sans">
|
<p className="text-muted-foreground mx-auto mb-12 max-w-2xl font-sans text-xl leading-relaxed">
|
||||||
Create professional invoices, manage clients, and track payments with a tool that feels as good as it looks.
|
{brand.tagline}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
|
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
|
||||||
<Link href="/auth/register">
|
<Link href="/auth/register">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
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"
|
className="shadow-primary/20 hover:shadow-primary/30 h-14 rounded-2xl px-10 text-lg shadow-xl transition-all duration-300 hover:shadow-2xl"
|
||||||
>
|
>
|
||||||
Start For Free
|
Start For Free
|
||||||
<ArrowRight className="ml-2 h-5 w-5" />
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
@@ -95,14 +96,14 @@ export default function HomePage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="h-14 px-10 text-lg rounded-2xl border-border/50 bg-background/50 hover:bg-background/80 backdrop-blur-sm"
|
className="border-border/50 bg-background/50 hover:bg-background/80 h-14 rounded-2xl px-10 text-lg backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
Learn More
|
Learn More
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 text-muted-foreground/80 flex flex-col items-center justify-center gap-2 text-sm sm:flex-row sm:gap-8">
|
<div className="text-muted-foreground/80 mt-16 flex flex-col items-center justify-center gap-2 text-sm sm:flex-row sm:gap-8">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Check className="text-primary h-4 w-4" />
|
<Check className="text-primary h-4 w-4" />
|
||||||
<span>No credit card required</span>
|
<span>No credit card required</span>
|
||||||
@@ -121,11 +122,12 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<section id="features" className="py-24 relative">
|
<section id="features" className="relative py-24">
|
||||||
<div className="container mx-auto px-4 relative z-10">
|
<div className="relative z-10 container mx-auto px-4">
|
||||||
<div className="mb-20 text-center">
|
<div className="mb-20 text-center">
|
||||||
<h2 className="text-foreground mb-6 text-4xl font-heading font-bold sm:text-5xl">
|
<h2 className="text-foreground font-heading mb-6 text-4xl font-bold sm:text-5xl">
|
||||||
Everything you need to <span className="italic text-primary">thrive</span>
|
Everything you need to{" "}
|
||||||
|
<span className="text-primary italic">thrive</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg">
|
<p className="text-muted-foreground mx-auto max-w-2xl text-lg">
|
||||||
Powerful features wrapped in a calm, focused interface.
|
Powerful features wrapped in a calm, focused interface.
|
||||||
@@ -137,28 +139,46 @@ export default function HomePage() {
|
|||||||
{
|
{
|
||||||
icon: Rocket,
|
icon: Rocket,
|
||||||
title: "Quick Setup",
|
title: "Quick Setup",
|
||||||
description: "Start creating invoices immediately. No complicated setup required.",
|
description:
|
||||||
items: ["Simple client management", "Professional templates", "Easy invoice sending"]
|
"Start creating invoices immediately. No complicated setup required.",
|
||||||
|
items: [
|
||||||
|
"Simple client management",
|
||||||
|
"Professional templates",
|
||||||
|
"Easy invoice sending",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
title: "Payment Tracking",
|
title: "Payment Tracking",
|
||||||
description: "Keep track of invoice status and monitor your payments effortlessly.",
|
description:
|
||||||
items: ["Invoice status tracking", "Payment history", "Overdue notifications"]
|
"Keep track of invoice status and monitor your payments effortlessly.",
|
||||||
|
items: [
|
||||||
|
"Invoice status tracking",
|
||||||
|
"Payment history",
|
||||||
|
"Overdue notifications",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
title: "Professional Features",
|
title: "Professional Features",
|
||||||
description: "Tools that make you look professional and get you paid faster.",
|
description:
|
||||||
items: ["PDF generation", "Custom tax rates", "Professional numbering"]
|
"Tools that make you look professional and get you paid faster.",
|
||||||
}
|
items: [
|
||||||
|
"PDF generation",
|
||||||
|
"Custom tax rates",
|
||||||
|
"Professional numbering",
|
||||||
|
],
|
||||||
|
},
|
||||||
].map((feature, i) => (
|
].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">
|
<Card
|
||||||
|
key={i}
|
||||||
|
className="group border-border/40 bg-background/60 backdrop-blur-xl transition-transform duration-500 hover:-translate-y-2"
|
||||||
|
>
|
||||||
<CardContent className="p-8">
|
<CardContent className="p-8">
|
||||||
<div className="bg-primary/10 text-primary mb-6 inline-flex rounded-2xl p-4">
|
<div className="bg-primary/10 text-primary mb-6 inline-flex rounded-2xl p-4">
|
||||||
<feature.icon className="h-8 w-8" />
|
<feature.icon className="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-foreground mb-4 text-2xl font-bold font-heading">
|
<h3 className="text-foreground font-heading mb-4 text-2xl font-bold">
|
||||||
{feature.title}
|
{feature.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||||
@@ -166,8 +186,11 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{feature.items.map((item, j) => (
|
{feature.items.map((item, j) => (
|
||||||
<li key={j} className="flex items-center gap-3 text-sm text-foreground/80">
|
<li
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
|
key={j}
|
||||||
|
className="text-foreground/80 flex items-center gap-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="bg-primary h-1.5 w-1.5 rounded-full" />
|
||||||
{item}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -180,39 +203,45 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Section */}
|
{/* Pricing Section */}
|
||||||
<section id="pricing" className="py-24 relative overflow-hidden">
|
<section id="pricing" className="relative overflow-hidden py-24">
|
||||||
<div className="container mx-auto px-4 relative z-10">
|
<div className="relative z-10 container mx-auto px-4">
|
||||||
<div className="max-w-4xl mx-auto text-center mb-16">
|
<div className="mx-auto mb-16 max-w-4xl text-center">
|
||||||
<h2 className="text-5xl font-heading font-bold mb-6">Simple Pricing</h2>
|
<h2 className="font-heading mb-6 text-5xl font-bold">
|
||||||
<p className="text-xl text-muted-foreground">Focus on your work, not on fees.</p>
|
Simple Pricing
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground text-xl">
|
||||||
|
Focus on your work, not on fees.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-md mx-auto">
|
<div className="mx-auto max-w-md">
|
||||||
<Card className="relative overflow-visible border-primary/50 shadow-2xl shadow-primary/5 bg-background/80 backdrop-blur-xl">
|
<Card className="border-primary/50 shadow-primary/5 bg-background/80 relative overflow-visible shadow-2xl 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">
|
<div className="bg-primary text-primary-foreground absolute -top-4 left-1/2 -translate-x-1/2 rounded-full px-6 py-1.5 text-sm font-medium shadow-lg">
|
||||||
Forever Free
|
Forever Free
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-10 text-center">
|
<CardContent className="p-10 text-center">
|
||||||
<div className="mb-2 text-6xl font-bold font-heading">$0</div>
|
<div className="font-heading mb-2 text-6xl font-bold">$0</div>
|
||||||
<div className="text-muted-foreground mb-8">No credit card required.</div>
|
<div className="text-muted-foreground mb-8">
|
||||||
|
No credit card required.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 mb-10 text-left pl-8">
|
<div className="mb-10 space-y-4 pl-8 text-left">
|
||||||
{[
|
{[
|
||||||
"Unlimited Invoices",
|
"Unlimited Invoices",
|
||||||
"Unlimited Clients",
|
"Unlimited Clients",
|
||||||
"PDF Downloads",
|
"PDF Downloads",
|
||||||
"Payment Tracking",
|
"Payment Tracking",
|
||||||
"Email Support"
|
"Email Support",
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<div key={i} className="flex items-center gap-3">
|
<div key={i} className="flex items-center gap-3">
|
||||||
<Check className="h-5 w-5 text-primary shrink-0" />
|
<Check className="text-primary h-5 w-5 shrink-0" />
|
||||||
<span className="text-foreground/90">{item}</span>
|
<span className="text-foreground/90">{item}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href="/auth/register" className="block">
|
<Link href="/auth/register" className="block">
|
||||||
<Button size="lg" className="w-full text-lg h-12 rounded-xl">
|
<Button size="lg" className="h-12 w-full rounded-xl text-lg">
|
||||||
Get Started
|
Get Started
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -223,20 +252,27 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="border-t border-border/40 bg-background/50 backdrop-blur-sm py-12 mt-12">
|
<footer className="border-border/40 bg-background/50 mt-12 border-t py-12 backdrop-blur-sm">
|
||||||
<div className="container mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-6">
|
<div className="container mx-auto flex flex-col items-center justify-between gap-6 px-6 md:flex-row">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Logo size="sm" />
|
<Logo size="sm" />
|
||||||
<span className="text-sm text-muted-foreground">© 2024 beenvoice</span>
|
<span className="text-muted-foreground text-sm">
|
||||||
|
© 2024 beenvoice
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-8 text-sm text-muted-foreground">
|
<div className="text-muted-foreground flex gap-8 text-sm">
|
||||||
<a href="#" className="hover:text-foreground transition-colors">Privacy</a>
|
<a href="#" className="hover:text-foreground transition-colors">
|
||||||
<a href="#" className="hover:text-foreground transition-colors">Terms</a>
|
Privacy
|
||||||
<a href="#" className="hover:text-foreground transition-colors">Contact</a>
|
</a>
|
||||||
|
<a href="#" className="hover:text-foreground transition-colors">
|
||||||
|
Terms
|
||||||
|
</a>
|
||||||
|
<a href="#" className="hover:text-foreground transition-colors">
|
||||||
|
Contact
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -10,6 +12,9 @@ interface LogoProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: "text-base",
|
sm: "text-base",
|
||||||
md: "text-xl",
|
md: "text-xl",
|
||||||
@@ -19,7 +24,15 @@ 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}
|
||||||
|
logoText={logoText}
|
||||||
|
icon={icon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +40,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 +52,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 +68,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
|
{logoText.slice(0, Math.ceil(logoText.length / 2))}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
<motion.span
|
<motion.span
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -59,7 +76,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
|
{logoText.slice(Math.ceil(logoText.length / 2))}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -71,19 +88,35 @@ function LogoContent({
|
|||||||
className,
|
className,
|
||||||
size,
|
size,
|
||||||
sizeClasses,
|
sizeClasses,
|
||||||
|
logoText,
|
||||||
|
icon,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
size: "sm" | "md" | "lg" | "xl" | "icon";
|
size: "sm" | "md" | "lg" | "xl" | "icon";
|
||||||
sizeClasses: Record<string, string>;
|
sizeClasses: Record<string, string>;
|
||||||
|
logoText: string;
|
||||||
|
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>
|
{logoText.slice(0, Math.ceil(logoText.length / 2))}
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground/70 font-bold tracking-tight">
|
||||||
|
{logoText.slice(Math.ceil(logoText.length / 2))}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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're looking for doesn't exist or has been
|
|
||||||
deleted.
|
|
||||||
</p>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/dashboard/invoices">Back to Invoices</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatusIcon =
|
|
||||||
statusIconConfig[invoice.status as keyof typeof statusIconConfig];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Status Alert */}
|
|
||||||
{isOverdue && (
|
|
||||||
<Card className="border-destructive/20 bg-destructive/10">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="text-destructive flex items-center gap-2">
|
|
||||||
<AlertCircle className="h-5 w-5" />
|
|
||||||
<span className="font-medium">This invoice is overdue</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="space-y-6 lg:col-span-2">
|
|
||||||
{/* Invoice Header Card */}
|
|
||||||
<Card className="bg-card border-border border">
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex 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)} · {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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,6 +76,23 @@ function InvoiceFormSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDefaultHourlyRate(value: unknown) {
|
||||||
|
if (typeof value !== "object" || value === null) return null;
|
||||||
|
|
||||||
|
const rate = (value as { defaultHourlyRate?: unknown }).defaultHourlyRate;
|
||||||
|
return typeof rate === "number" ? rate : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainTextToHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -72,12 +100,14 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
// State
|
// State
|
||||||
const [formData, setFormData] = useState<InvoiceFormData>({
|
const [formData, setFormData] = useState<InvoiceFormData>({
|
||||||
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
||||||
|
invoicePrefix: "#",
|
||||||
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,
|
||||||
@@ -97,11 +127,14 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
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 } =
|
||||||
@@ -126,26 +159,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
||||||
// ... (Mapping logic same as before)
|
// ... (Mapping logic same as before)
|
||||||
const mappedItems: InvoiceItem[] =
|
const mappedItems: InvoiceItem[] =
|
||||||
existingInvoice.items
|
existingInvoice.items?.map((item) => ({
|
||||||
?.map((item) => ({
|
id: crypto.randomUUID(),
|
||||||
id: crypto.randomUUID(),
|
date: new Date(item.date),
|
||||||
date: new Date(item.date),
|
description: item.description,
|
||||||
description: item.description,
|
hours: item.hours,
|
||||||
hours: item.hours,
|
rate: item.rate,
|
||||||
rate: item.rate,
|
amount: item.amount,
|
||||||
amount: item.amount,
|
})) || [];
|
||||||
}))
|
|
||||||
.sort(
|
|
||||||
(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 +217,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 +311,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 +387,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 +434,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 +457,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 +476,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 +489,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 +506,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 +556,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 +585,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 +673,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 +726,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 +779,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 +806,122 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="preview"
|
||||||
|
className="mt-6 focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
value={previewTab}
|
||||||
|
onValueChange={setPreviewTab}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1">
|
||||||
|
<TabsTrigger
|
||||||
|
value="pdf"
|
||||||
|
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
PDF
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="email"
|
||||||
|
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="pdf" className="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex gap-2">
|
||||||
|
<FileText className="h-5 w-5" /> PDF Preview
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="bg-muted/20 h-[760px] overflow-hidden border-t">
|
||||||
|
{!formData.clientId ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||||
|
Select a client to generate the PDF preview.
|
||||||
|
</div>
|
||||||
|
) : formData.items.some(
|
||||||
|
(item) => item.description.trim() === "",
|
||||||
|
) ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||||
|
Add descriptions for all line items to generate the
|
||||||
|
PDF preview.
|
||||||
|
</div>
|
||||||
|
) : pdfPreviewLoading && !pdfPreview ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||||
|
Generating server PDF preview...
|
||||||
|
</div>
|
||||||
|
) : pdfPreview ? (
|
||||||
|
<iframe
|
||||||
|
title="Server-generated PDF preview"
|
||||||
|
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
|
||||||
|
className="h-full w-full border-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||||
|
PDF preview will appear here.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="email" className="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex gap-2">
|
||||||
|
<Mail className="h-5 w-5" /> Email Preview
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<EmailPreview
|
||||||
|
subject={`Invoice ${formData.invoiceNumber} from ${
|
||||||
|
selectedBusiness?.name ?? "Your Business"
|
||||||
|
}`}
|
||||||
|
fromEmail={selectedBusiness?.email ?? ""}
|
||||||
|
toEmail={selectedClient?.email ?? ""}
|
||||||
|
content=""
|
||||||
|
customMessage={emailPreviewMessage}
|
||||||
|
invoice={{
|
||||||
|
invoiceNumber: formData.invoiceNumber,
|
||||||
|
issueDate: formData.issueDate,
|
||||||
|
dueDate: formData.dueDate,
|
||||||
|
taxRate: formData.taxRate,
|
||||||
|
status: formData.status,
|
||||||
|
totalAmount: totals.total,
|
||||||
|
currency: formData.currency,
|
||||||
|
client: selectedClient
|
||||||
|
? {
|
||||||
|
name: selectedClient.name,
|
||||||
|
email: selectedClient.email,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
business: selectedBusiness
|
||||||
|
? {
|
||||||
|
name: selectedBusiness.name,
|
||||||
|
email: selectedBusiness.email,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
items: formData.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
date: item.date,
|
||||||
|
description: item.description,
|
||||||
|
hours: item.hours,
|
||||||
|
rate: item.rate,
|
||||||
|
amount: item.hours * item.rate,
|
||||||
|
})),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -33,9 +28,6 @@ interface InvoiceLineItemsProps {
|
|||||||
field: string,
|
field: string,
|
||||||
value: string | number | Date,
|
value: string | number | Date,
|
||||||
) => void;
|
) => void;
|
||||||
onMoveUp: (index: number) => void;
|
|
||||||
onMoveDown: (index: number) => void;
|
|
||||||
onReorderItems: (items: InvoiceItem[]) => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface SendEmailDialogProps {
|
|||||||
dueDate: Date;
|
dueDate: Date;
|
||||||
status: string;
|
status: string;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
|
currency?: string | null;
|
||||||
client?: {
|
client?: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -47,8 +48,11 @@ interface SendEmailDialogProps {
|
|||||||
};
|
};
|
||||||
items?: Array<{
|
items?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
date?: Date;
|
||||||
|
description?: string;
|
||||||
hours: number;
|
hours: number;
|
||||||
rate: number;
|
rate: number;
|
||||||
|
amount?: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
onEmailSent?: () => void;
|
onEmailSent?: () => void;
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { Menu } from "lucide-react";
|
|||||||
import { Logo } from "~/components/branding/logo";
|
import { Logo } from "~/components/branding/logo";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
||||||
|
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||||
|
|
||||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||||
const { isCollapsed } = useSidebar();
|
const { isCollapsed } = useSidebar();
|
||||||
|
const { sidebarStyle } = useAppearance();
|
||||||
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
|
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,7 +23,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
|||||||
</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="fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b bg-background/80 px-4 backdrop-blur-md md:hidden">
|
||||||
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
|
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant="outline" size="icon" className="h-10 w-10 bg-background shadow-sm" suppressHydrationWarning>
|
<Button variant="outline" size="icon" className="h-10 w-10 bg-background shadow-sm" suppressHydrationWarning>
|
||||||
@@ -47,13 +49,14 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 min-h-screen min-w-0 transition-all duration-300 ease-in-out",
|
"flex-1 min-h-screen min-w-0 transition-all duration-300 ease-in-out",
|
||||||
// Desktop margins based on collapsed state
|
|
||||||
"md:ml-0",
|
"md:ml-0",
|
||||||
// Sidebar is fixed at left: 1rem (16px), width: 16rem (256px) or 4rem (64px)
|
sidebarStyle === "floating"
|
||||||
// We need margin-left = left + width + gap
|
? isCollapsed
|
||||||
// Expanded: 16px + 256px + 16px (gap) = 288px (18rem)
|
? "md:ml-24"
|
||||||
// Collapsed: 16px + 64px + 16px (gap) = 96px (6rem)
|
: "md:ml-[18rem]"
|
||||||
isCollapsed ? "md:ml-24" : "md:ml-[18rem]"
|
: isCollapsed
|
||||||
|
? "md:ml-16"
|
||||||
|
: "md:ml-64",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="p-4 pt-16 md:pt-4">
|
<div className="p-4 pt-16 md:pt-4">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ 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 rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden relative">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent pointer-events-none" />
|
<div className="platform-header-gradient absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent pointer-events-none" />
|
||||||
<div className="p-6 relative">
|
<div className="platform-header-content p-6 relative">
|
||||||
<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 sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
|
|||||||
@@ -25,6 +25,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 +37,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;
|
||||||
@@ -214,9 +216,11 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
|
|||||||
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"
|
? "top-4 bottom-4 left-4 border-border/50 rounded-3xl border bg-background/80 shadow-xl backdrop-blur-xl"
|
||||||
|
: "top-0 bottom-0 left-0 rounded-none border-r border-border bg-background shadow-none",
|
||||||
|
isCollapsed ? "w-16" : "w-64",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{SidebarContent}
|
{SidebarContent}
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
defaultFontPreference,
|
||||||
|
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;
|
||||||
|
fontPreference: FontPreference;
|
||||||
|
bodyFontPreference: FontPreference;
|
||||||
|
headingFontPreference: FontPreference;
|
||||||
|
radiusPreference: RadiusPreference;
|
||||||
|
sidebarStyle: SidebarStyle;
|
||||||
|
colorMode: ColorMode;
|
||||||
|
colorTheme: ColorTheme;
|
||||||
|
customColor?: string;
|
||||||
|
brandName: string;
|
||||||
|
brandTagline: string;
|
||||||
|
brandLogoText: string;
|
||||||
|
brandIcon: string;
|
||||||
|
pdfTemplate: "classic" | "minimal";
|
||||||
|
pdfAccentColor: string;
|
||||||
|
pdfFooterText: string;
|
||||||
|
pdfShowLogo: boolean;
|
||||||
|
pdfShowPageNumbers: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppearancePatch = Partial<AppearancePreferences>;
|
||||||
|
|
||||||
|
type ServerAppearance = {
|
||||||
|
interfaceTheme: InterfaceTheme;
|
||||||
|
fontPreference: FontPreference;
|
||||||
|
bodyFontPreference: FontPreference;
|
||||||
|
headingFontPreference: FontPreference;
|
||||||
|
radiusPreference: RadiusPreference;
|
||||||
|
sidebarStyle: SidebarStyle;
|
||||||
|
theme: ColorMode;
|
||||||
|
colorTheme: ColorTheme;
|
||||||
|
customColor?: string;
|
||||||
|
brandName: string;
|
||||||
|
brandTagline: string;
|
||||||
|
brandLogoText: string;
|
||||||
|
brandIcon: string;
|
||||||
|
pdfTemplate: "classic" | "minimal";
|
||||||
|
pdfAccentColor: string;
|
||||||
|
pdfFooterText: string;
|
||||||
|
pdfShowLogo: boolean;
|
||||||
|
pdfShowPageNumbers: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppearanceContextValue = AppearancePreferences & {
|
||||||
|
updateAppearance: (patch: AppearancePatch) => void;
|
||||||
|
isUpdating: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = "bv.appearance";
|
||||||
|
|
||||||
|
const defaultAppearance: AppearancePreferences = {
|
||||||
|
interfaceTheme: defaultInterfaceTheme,
|
||||||
|
fontPreference: defaultFontPreference,
|
||||||
|
bodyFontPreference: defaultBodyFontPreference,
|
||||||
|
headingFontPreference: defaultHeadingFontPreference,
|
||||||
|
radiusPreference: defaultRadiusPreference,
|
||||||
|
sidebarStyle: defaultSidebarStyle,
|
||||||
|
colorMode: "system",
|
||||||
|
colorTheme: "slate",
|
||||||
|
brandName: defaultBrand.name,
|
||||||
|
brandTagline: defaultBrand.tagline,
|
||||||
|
brandLogoText: defaultBrand.logoText,
|
||||||
|
brandIcon: defaultBrand.icon,
|
||||||
|
pdfTemplate: "classic",
|
||||||
|
pdfAccentColor: "#111827",
|
||||||
|
pdfFooterText: "Professional Invoicing",
|
||||||
|
pdfShowLogo: true,
|
||||||
|
pdfShowPageNumbers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
|
||||||
|
|
||||||
|
function getServerAppearancePatch(
|
||||||
|
serverAppearance: ServerAppearance,
|
||||||
|
): AppearancePatch {
|
||||||
|
return {
|
||||||
|
interfaceTheme: serverAppearance.interfaceTheme,
|
||||||
|
fontPreference: serverAppearance.fontPreference,
|
||||||
|
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 isInterfaceTheme(value: unknown): value is InterfaceTheme {
|
||||||
|
return (
|
||||||
|
value === "beenvoice" ||
|
||||||
|
value === "shadcn" ||
|
||||||
|
value === "minimal" ||
|
||||||
|
value === "editorial"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFontPreference(value: unknown): value is FontPreference {
|
||||||
|
return (
|
||||||
|
value === "brand" ||
|
||||||
|
value === "platform" ||
|
||||||
|
value === "inter" ||
|
||||||
|
value === "serif"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isColorMode(value: unknown): value is ColorMode {
|
||||||
|
return value === "light" || value === "dark" || value === "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isColorTheme(value: unknown): value is ColorTheme {
|
||||||
|
return (
|
||||||
|
value === "slate" ||
|
||||||
|
value === "blue" ||
|
||||||
|
value === "green" ||
|
||||||
|
value === "rose" ||
|
||||||
|
value === "orange" ||
|
||||||
|
value === "custom"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRadiusPreference(value: unknown): value is RadiusPreference {
|
||||||
|
return (
|
||||||
|
value === "none" ||
|
||||||
|
value === "sm" ||
|
||||||
|
value === "md" ||
|
||||||
|
value === "lg" ||
|
||||||
|
value === "xl"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSidebarStyle(value: unknown): value is SidebarStyle {
|
||||||
|
return value === "floating" || value === "docked";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredAppearance(): Partial<AppearancePreferences> | null {
|
||||||
|
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,
|
||||||
|
fontPreference: isFontPreference(parsed.fontPreference)
|
||||||
|
? parsed.fontPreference
|
||||||
|
: 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:
|
||||||
|
typeof parsed.customColor === "string" ? 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:
|
||||||
|
parsed.pdfTemplate === "classic" || parsed.pdfTemplate === "minimal"
|
||||||
|
? 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.font = prefs.fontPreference;
|
||||||
|
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 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 { 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) => {
|
||||||
|
setAppearance((prev) => {
|
||||||
|
const next = { ...prev, ...patch };
|
||||||
|
applyAppearance(next);
|
||||||
|
writeStoredAppearance(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
updateMutation.mutate({
|
||||||
|
interfaceTheme: patch.interfaceTheme,
|
||||||
|
fontPreference: patch.fontPreference,
|
||||||
|
bodyFontPreference: patch.bodyFontPreference,
|
||||||
|
headingFontPreference: patch.headingFontPreference,
|
||||||
|
radiusPreference: patch.radiusPreference,
|
||||||
|
sidebarStyle: patch.sidebarStyle,
|
||||||
|
theme: patch.colorMode,
|
||||||
|
colorTheme: patch.colorTheme,
|
||||||
|
customColor: patch.customColor,
|
||||||
|
brandName: patch.brandName,
|
||||||
|
brandTagline: patch.brandTagline,
|
||||||
|
brandLogoText: patch.brandLogoText,
|
||||||
|
brandIcon: patch.brandIcon,
|
||||||
|
pdfTemplate: patch.pdfTemplate,
|
||||||
|
pdfAccentColor: patch.pdfAccentColor,
|
||||||
|
pdfFooterText: patch.pdfFooterText,
|
||||||
|
pdfShowLogo: patch.pdfShowLogo,
|
||||||
|
pdfShowPageNumbers: patch.pdfShowPageNumbers,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo<AppearanceContextValue>(
|
||||||
|
() => ({
|
||||||
|
...appearance,
|
||||||
|
updateAppearance,
|
||||||
|
isUpdating: updateMutation.isPending,
|
||||||
|
}),
|
||||||
|
[appearance, updateAppearance, 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;
|
||||||
|
}
|
||||||
+38
-4
@@ -13,10 +13,7 @@ export const env = createEnv({
|
|||||||
: z.string().optional(),
|
: z.string().optional(),
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
BETTER_AUTH_URL: z.string().url().optional(),
|
BETTER_AUTH_URL: z.string().url().optional(),
|
||||||
RESEND_API_KEY:
|
RESEND_API_KEY: z.string().min(1).optional(),
|
||||||
process.env.NODE_ENV === "production"
|
|
||||||
? z.string().min(1)
|
|
||||||
: z.string().min(1).optional(),
|
|
||||||
RESEND_DOMAIN: z.string().optional(),
|
RESEND_DOMAIN: z.string().optional(),
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(["development", "test", "production"])
|
.enum(["development", "test", "production"])
|
||||||
@@ -26,6 +23,7 @@ export const env = createEnv({
|
|||||||
AUTHENTIK_ISSUER: z.string().url().optional(),
|
AUTHENTIK_ISSUER: z.string().url().optional(),
|
||||||
AUTHENTIK_CLIENT_ID: z.string().optional(),
|
AUTHENTIK_CLIENT_ID: z.string().optional(),
|
||||||
AUTHENTIK_CLIENT_SECRET: z.string().optional(),
|
AUTHENTIK_CLIENT_SECRET: z.string().optional(),
|
||||||
|
AUTHENTIK_ORIGIN: z.string().url().optional(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +35,27 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
|
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().optional(),
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().optional(),
|
||||||
|
NEXT_PUBLIC_AUTHENTIK_ENABLED: z.coerce.boolean().optional(),
|
||||||
|
NEXT_PUBLIC_BRAND_NAME: z.string().optional(),
|
||||||
|
NEXT_PUBLIC_BRAND_TAGLINE: z.string().optional(),
|
||||||
|
NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(),
|
||||||
|
NEXT_PUBLIC_BRAND_ICON: z.string().optional(),
|
||||||
|
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME: z
|
||||||
|
.enum(["beenvoice", "shadcn", "minimal", "editorial"])
|
||||||
|
.optional(),
|
||||||
|
NEXT_PUBLIC_DEFAULT_FONT: z
|
||||||
|
.enum(["brand", "platform", "inter", "serif"])
|
||||||
|
.optional(),
|
||||||
|
NEXT_PUBLIC_DEFAULT_BODY_FONT: z
|
||||||
|
.enum(["brand", "platform", "inter", "serif"])
|
||||||
|
.optional(),
|
||||||
|
NEXT_PUBLIC_DEFAULT_HEADING_FONT: z
|
||||||
|
.enum(["brand", "platform", "inter", "serif"])
|
||||||
|
.optional(),
|
||||||
|
NEXT_PUBLIC_DEFAULT_RADIUS: z.enum(["none", "sm", "md", "lg", "xl"]).optional(),
|
||||||
|
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE: z
|
||||||
|
.enum(["floating", "docked"])
|
||||||
|
.optional(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,9 +73,24 @@ export const env = createEnv({
|
|||||||
AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER,
|
AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER,
|
||||||
AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID,
|
AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID,
|
||||||
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
|
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
|
||||||
|
AUTHENTIK_ORIGIN: process.env.AUTHENTIK_ORIGIN,
|
||||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||||
|
NEXT_PUBLIC_AUTHENTIK_ENABLED: process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED,
|
||||||
|
NEXT_PUBLIC_BRAND_NAME: process.env.NEXT_PUBLIC_BRAND_NAME,
|
||||||
|
NEXT_PUBLIC_BRAND_TAGLINE: process.env.NEXT_PUBLIC_BRAND_TAGLINE,
|
||||||
|
NEXT_PUBLIC_BRAND_LOGO_TEXT: process.env.NEXT_PUBLIC_BRAND_LOGO_TEXT,
|
||||||
|
NEXT_PUBLIC_BRAND_ICON: process.env.NEXT_PUBLIC_BRAND_ICON,
|
||||||
|
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME:
|
||||||
|
process.env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME,
|
||||||
|
NEXT_PUBLIC_DEFAULT_FONT: process.env.NEXT_PUBLIC_DEFAULT_FONT,
|
||||||
|
NEXT_PUBLIC_DEFAULT_BODY_FONT: process.env.NEXT_PUBLIC_DEFAULT_BODY_FONT,
|
||||||
|
NEXT_PUBLIC_DEFAULT_HEADING_FONT:
|
||||||
|
process.env.NEXT_PUBLIC_DEFAULT_HEADING_FONT,
|
||||||
|
NEXT_PUBLIC_DEFAULT_RADIUS: process.env.NEXT_PUBLIC_DEFAULT_RADIUS,
|
||||||
|
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE:
|
||||||
|
process.env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE,
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
||||||
|
|||||||
+53
-45
@@ -5,55 +5,63 @@ import { genericOAuth } from "better-auth/plugins";
|
|||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import * as schema from "~/server/db/schema";
|
import * as schema from "~/server/db/schema";
|
||||||
|
|
||||||
|
const authentikEnabled = Boolean(
|
||||||
|
process.env.AUTHENTIK_ISSUER &&
|
||||||
|
process.env.AUTHENTIK_CLIENT_ID &&
|
||||||
|
process.env.AUTHENTIK_CLIENT_SECRET,
|
||||||
|
);
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
schema: {
|
schema: {
|
||||||
user: schema.users,
|
user: schema.users,
|
||||||
session: schema.sessions,
|
session: schema.sessions,
|
||||||
account: schema.accounts,
|
account: schema.accounts,
|
||||||
verification: schema.verificationTokens,
|
verification: schema.verificationTokens,
|
||||||
ssoProvider: schema.ssoProviders,
|
ssoProvider: schema.ssoProviders,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
trustedOrigins: [
|
trustedOrigins: [
|
||||||
"https://beenvoice.soconnor.dev",
|
"https://beenvoice.soconnor.dev",
|
||||||
"https://auth.soconnor.dev", // Authentik IdP for OIDC discovery
|
...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
|
||||||
],
|
],
|
||||||
|
...(authentikEnabled && {
|
||||||
accountLinking: {
|
accountLinking: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
trustedProviders: ["authentik"],
|
trustedProviders: ["authentik"],
|
||||||
},
|
},
|
||||||
emailAndPassword: {
|
}),
|
||||||
enabled: true,
|
emailAndPassword: {
|
||||||
password: {
|
enabled: true,
|
||||||
hash: async (password) => {
|
password: {
|
||||||
const bcrypt = await import("bcryptjs");
|
hash: async (password) => {
|
||||||
return bcrypt.hash(password, 12);
|
const bcrypt = await import("bcryptjs");
|
||||||
},
|
return bcrypt.hash(password, 12);
|
||||||
verify: async ({ hash, password }) => {
|
},
|
||||||
const bcrypt = await import("bcryptjs");
|
verify: async ({ hash, password }) => {
|
||||||
return bcrypt.compare(password, hash);
|
const bcrypt = await import("bcryptjs");
|
||||||
},
|
return bcrypt.compare(password, hash);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
},
|
||||||
nextCookies(),
|
plugins: [
|
||||||
genericOAuth({
|
nextCookies(),
|
||||||
|
...(authentikEnabled
|
||||||
|
? [
|
||||||
|
genericOAuth({
|
||||||
config: [
|
config: [
|
||||||
{
|
{
|
||||||
providerId: "authentik",
|
providerId: "authentik",
|
||||||
clientId: process.env.AUTHENTIK_CLIENT_ID!,
|
clientId: process.env.AUTHENTIK_CLIENT_ID!,
|
||||||
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
|
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
|
||||||
discoveryUrl: `${process.env.AUTHENTIK_ISSUER}/.well-known/openid-configuration`,
|
discoveryUrl: `${process.env.AUTHENTIK_ISSUER}/.well-known/openid-configuration`,
|
||||||
// Explicit endpoints to ensure correct routing in production
|
scopes: ["openid", "email", "profile"],
|
||||||
authorizationUrl: "https://auth.soconnor.dev/application/o/authorize/",
|
pkce: true,
|
||||||
tokenUrl: "https://auth.soconnor.dev/application/o/token/",
|
},
|
||||||
userInfoUrl: "https://auth.soconnor.dev/application/o/userinfo/",
|
|
||||||
scopes: ["openid", "email", "profile"],
|
|
||||||
pkce: true,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
export type InterfaceTheme = "beenvoice" | "shadcn" | "minimal" | "editorial";
|
||||||
|
export type FontPreference = "brand" | "platform" | "inter" | "serif";
|
||||||
|
export type RadiusPreference = "none" | "sm" | "md" | "lg" | "xl";
|
||||||
|
export type SidebarStyle = "floating" | "docked";
|
||||||
|
export type ColorMode = "light" | "dark" | "system";
|
||||||
|
export type ColorTheme =
|
||||||
|
| "slate"
|
||||||
|
| "blue"
|
||||||
|
| "green"
|
||||||
|
| "rose"
|
||||||
|
| "orange"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
export const interfaceThemes: {
|
||||||
|
value: InterfaceTheme;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: "beenvoice",
|
||||||
|
label: "beenvoice",
|
||||||
|
description: "Opinionated brand system with expressive headings.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "shadcn",
|
||||||
|
label: "shadcn/ui",
|
||||||
|
description: "A plain shadcn baseline for white-label starts.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "minimal",
|
||||||
|
label: "Minimal",
|
||||||
|
description: "Quiet surfaces, lower contrast, and restrained chrome.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "editorial",
|
||||||
|
label: "Editorial",
|
||||||
|
description: "A warmer presentation style for service-led brands.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const themePresets: Record<
|
||||||
|
InterfaceTheme,
|
||||||
|
{
|
||||||
|
interfaceTheme: InterfaceTheme;
|
||||||
|
bodyFontPreference: FontPreference;
|
||||||
|
headingFontPreference: FontPreference;
|
||||||
|
colorTheme: ColorTheme;
|
||||||
|
radiusPreference: RadiusPreference;
|
||||||
|
sidebarStyle: SidebarStyle;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
beenvoice: {
|
||||||
|
interfaceTheme: "beenvoice",
|
||||||
|
bodyFontPreference: "brand",
|
||||||
|
headingFontPreference: "brand",
|
||||||
|
colorTheme: "slate",
|
||||||
|
radiusPreference: "xl",
|
||||||
|
sidebarStyle: "floating",
|
||||||
|
},
|
||||||
|
shadcn: {
|
||||||
|
interfaceTheme: "shadcn",
|
||||||
|
bodyFontPreference: "inter",
|
||||||
|
headingFontPreference: "inter",
|
||||||
|
colorTheme: "slate",
|
||||||
|
radiusPreference: "md",
|
||||||
|
sidebarStyle: "docked",
|
||||||
|
},
|
||||||
|
minimal: {
|
||||||
|
interfaceTheme: "minimal",
|
||||||
|
bodyFontPreference: "platform",
|
||||||
|
headingFontPreference: "platform",
|
||||||
|
colorTheme: "slate",
|
||||||
|
radiusPreference: "sm",
|
||||||
|
sidebarStyle: "docked",
|
||||||
|
},
|
||||||
|
editorial: {
|
||||||
|
interfaceTheme: "editorial",
|
||||||
|
bodyFontPreference: "platform",
|
||||||
|
headingFontPreference: "serif",
|
||||||
|
colorTheme: "rose",
|
||||||
|
radiusPreference: "lg",
|
||||||
|
sidebarStyle: "floating",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fontPreferences: {
|
||||||
|
value: FontPreference;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: "brand",
|
||||||
|
label: "Brand",
|
||||||
|
description: "Inter body with Playfair headings.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "platform",
|
||||||
|
label: "Platform",
|
||||||
|
description: "Native system fonts for the current OS.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "inter",
|
||||||
|
label: "Inter",
|
||||||
|
description: "Inter for both body and headings.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "serif",
|
||||||
|
label: "Editorial",
|
||||||
|
description: "Serif headings with system body text.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const bodyFontPreferences: {
|
||||||
|
value: FontPreference;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: "brand",
|
||||||
|
label: "Brand Sans",
|
||||||
|
description: "Inter body text for a clean product feel.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "platform",
|
||||||
|
label: "Platform",
|
||||||
|
description: "Native system body text for the current OS.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "inter",
|
||||||
|
label: "Inter",
|
||||||
|
description: "Inter body text, explicitly selected.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "serif",
|
||||||
|
label: "Serif",
|
||||||
|
description: "Georgia-style body text for editorial deployments.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const headingFontPreferences: {
|
||||||
|
value: FontPreference;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: "brand",
|
||||||
|
label: "Brand Serif",
|
||||||
|
description: "Playfair headings for the BeenVoice identity.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "platform",
|
||||||
|
label: "Platform",
|
||||||
|
description: "Native system headings for a neutral app feel.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "inter",
|
||||||
|
label: "Inter",
|
||||||
|
description: "Inter headings for a plain shadcn-style baseline.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "serif",
|
||||||
|
label: "Editorial",
|
||||||
|
description: "Playfair headings with a stronger editorial tone.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const radiusPreferences: {
|
||||||
|
value: RadiusPreference;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{ value: "none", label: "Square", description: "No rounded corners." },
|
||||||
|
{ value: "sm", label: "Small", description: "Subtle 4px rounding." },
|
||||||
|
{ value: "md", label: "Medium", description: "Standard 8px rounding." },
|
||||||
|
{ value: "lg", label: "Large", description: "Soft 12px rounding." },
|
||||||
|
{
|
||||||
|
value: "xl",
|
||||||
|
label: "Extra Large",
|
||||||
|
description: "Expressive 16px rounding.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const sidebarStyles: {
|
||||||
|
value: SidebarStyle;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: "floating",
|
||||||
|
label: "Floating",
|
||||||
|
description: "Inset navigation with rounded edges and elevation.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "docked",
|
||||||
|
label: "Flush",
|
||||||
|
description: "Full-height navigation aligned to the viewport edge.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const colorThemes: {
|
||||||
|
value: ColorTheme;
|
||||||
|
label: string;
|
||||||
|
swatch: string;
|
||||||
|
}[] = [
|
||||||
|
{ value: "slate", label: "Slate", swatch: "hsl(240 5.9% 10%)" },
|
||||||
|
{ value: "blue", label: "Blue", swatch: "hsl(221.2 83.2% 53.3%)" },
|
||||||
|
{ value: "green", label: "Green", swatch: "hsl(142.1 76.2% 36.3%)" },
|
||||||
|
{ value: "rose", label: "Rose", swatch: "hsl(346.8 77.2% 49.8%)" },
|
||||||
|
{ value: "orange", label: "Orange", swatch: "hsl(24.6 95% 53.1%)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const colorModes: {
|
||||||
|
value: ColorMode;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{ value: "system", label: "System", description: "Follow device setting." },
|
||||||
|
{ value: "light", label: "Light", description: "Always use light mode." },
|
||||||
|
{ value: "dark", label: "Dark", description: "Always use dark mode." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const defaultInterfaceTheme: InterfaceTheme =
|
||||||
|
env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? "beenvoice";
|
||||||
|
|
||||||
|
export const defaultFontPreference: FontPreference =
|
||||||
|
env.NEXT_PUBLIC_DEFAULT_FONT ?? "brand";
|
||||||
|
|
||||||
|
export const defaultBodyFontPreference: FontPreference =
|
||||||
|
env.NEXT_PUBLIC_DEFAULT_BODY_FONT ?? defaultFontPreference;
|
||||||
|
|
||||||
|
export const defaultHeadingFontPreference: FontPreference =
|
||||||
|
env.NEXT_PUBLIC_DEFAULT_HEADING_FONT ?? defaultFontPreference;
|
||||||
|
|
||||||
|
export const defaultRadiusPreference: RadiusPreference =
|
||||||
|
env.NEXT_PUBLIC_DEFAULT_RADIUS ?? "xl";
|
||||||
|
|
||||||
|
export const defaultSidebarStyle: SidebarStyle =
|
||||||
|
env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? "floating";
|
||||||
|
|
||||||
|
export const brand = {
|
||||||
|
name: env.NEXT_PUBLIC_BRAND_NAME ?? "beenvoice",
|
||||||
|
tagline:
|
||||||
|
env.NEXT_PUBLIC_BRAND_TAGLINE ??
|
||||||
|
"Simple and efficient invoicing for freelancers and small businesses",
|
||||||
|
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
|
||||||
|
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
|
||||||
|
};
|
||||||
@@ -6,7 +6,7 @@ interface InvoiceEmailTemplateProps {
|
|||||||
status: string;
|
status: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
notes?: string | null;
|
currency?: string | null;
|
||||||
client: {
|
client: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -57,7 +57,7 @@ export function generateInvoiceEmailTemplate({
|
|||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: invoice.currency ?? "USD",
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -459,8 +459,6 @@ export function generateInvoiceEmailTemplate({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="attachment-notice">
|
<div class="attachment-notice">
|
||||||
<div class="attachment-icon"></div>
|
<div class="attachment-icon"></div>
|
||||||
<div class="attachment-text">
|
<div class="attachment-text">
|
||||||
@@ -540,8 +538,6 @@ Subtotal: ${formatCurrency(subtotal)}${
|
|||||||
}
|
}
|
||||||
Total: ${formatCurrency(total)}
|
Total: ${formatCurrency(total)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ATTACHMENT
|
ATTACHMENT
|
||||||
═══════════════
|
═══════════════
|
||||||
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
|
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const EXPENSE_CATEGORIES = [
|
||||||
|
"Travel",
|
||||||
|
"Meals & Entertainment",
|
||||||
|
"Software & Subscriptions",
|
||||||
|
"Hardware & Equipment",
|
||||||
|
"Office Supplies",
|
||||||
|
"Marketing",
|
||||||
|
"Professional Services",
|
||||||
|
"Utilities",
|
||||||
|
"Other",
|
||||||
|
] as const;
|
||||||
+239
-322
@@ -56,11 +56,13 @@ function downloadBlob(blob: Blob, filename: string): void {
|
|||||||
|
|
||||||
interface InvoiceData {
|
interface InvoiceData {
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
|
invoicePrefix?: string | null;
|
||||||
issueDate: Date;
|
issueDate: Date;
|
||||||
dueDate: Date;
|
dueDate: Date;
|
||||||
status: string;
|
status: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
|
currency?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
business?: {
|
business?: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -96,6 +98,26 @@ interface InvoiceData {
|
|||||||
} | null> | null;
|
} | null> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PDFGenerationSettings {
|
||||||
|
pdfTemplate?: "classic" | "minimal";
|
||||||
|
pdfAccentColor?: string;
|
||||||
|
pdfFooterText?: string;
|
||||||
|
pdfShowLogo?: boolean;
|
||||||
|
pdfShowPageNumbers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPDFSettings: Required<PDFGenerationSettings> = {
|
||||||
|
pdfTemplate: "classic",
|
||||||
|
pdfAccentColor: "#111827",
|
||||||
|
pdfFooterText: "Professional Invoicing",
|
||||||
|
pdfShowLogo: true,
|
||||||
|
pdfShowPageNumbers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolvePDFSettings(settings?: PDFGenerationSettings) {
|
||||||
|
return { ...defaultPDFSettings, ...settings };
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: {
|
page: {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
@@ -321,7 +343,6 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
// Table styles
|
// Table styles
|
||||||
tableContainer: {
|
tableContainer: {
|
||||||
flex: 1,
|
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -517,10 +538,10 @@ const styles = StyleSheet.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number, currency = "USD") => {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency,
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -565,212 +586,27 @@ const getStatusStyle = (status: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to estimate text height based on content and width
|
function getColumnWidths(showRate: boolean) {
|
||||||
function estimateTextHeight(
|
return showRate
|
||||||
text: string,
|
? {
|
||||||
maxWidth: number,
|
date: "15%",
|
||||||
fontSize = 10,
|
description: "40%",
|
||||||
lineHeight = 1.3,
|
hours: "12%",
|
||||||
): number {
|
rate: "15%",
|
||||||
if (!text) return fontSize * lineHeight;
|
amount: "18%",
|
||||||
|
}
|
||||||
// Rough character width estimation for Helvetica at given font size
|
: { date: "15%", description: "48%", hours: "14%", amount: "23%" };
|
||||||
const avgCharWidth = fontSize * 0.6;
|
|
||||||
const maxCharsPerLine = Math.floor(maxWidth / avgCharWidth);
|
|
||||||
|
|
||||||
if (maxCharsPerLine <= 0) return fontSize * lineHeight;
|
|
||||||
|
|
||||||
const lines = Math.ceil(text.length / maxCharsPerLine);
|
|
||||||
return lines * fontSize * lineHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate estimated height for a table row based on actual content
|
|
||||||
function calculateRowHeight(
|
|
||||||
item: NonNullable<InvoiceData["items"]>[0],
|
|
||||||
): number {
|
|
||||||
if (!item) return 18; // fallback
|
|
||||||
|
|
||||||
const basePadding = 8; // Row padding
|
|
||||||
const fontSize = 10;
|
|
||||||
const lineHeight = 1.3;
|
|
||||||
|
|
||||||
// Description column is 40% of table width
|
|
||||||
// Table width is roughly 512 points (letter width - margins)
|
|
||||||
const descriptionWidth = 512 * 0.4;
|
|
||||||
|
|
||||||
const descriptionHeight = estimateTextHeight(
|
|
||||||
item.description,
|
|
||||||
descriptionWidth,
|
|
||||||
fontSize,
|
|
||||||
lineHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Minimum row height for other columns
|
|
||||||
const minRowHeight = fontSize * lineHeight;
|
|
||||||
|
|
||||||
// Row height is the maximum of description height and minimum height, plus padding
|
|
||||||
// Ensure minimum row height of 24 points for readability
|
|
||||||
return Math.max(descriptionHeight, minRowHeight, 24) + basePadding;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic pagination calculation based on actual content
|
|
||||||
function calculateItemsForPage(
|
|
||||||
items: NonNullable<InvoiceData["items"]>,
|
|
||||||
startIndex: number,
|
|
||||||
isFirstPage: boolean,
|
|
||||||
hasNotes: boolean,
|
|
||||||
): number {
|
|
||||||
// Estimate available space in points (1 point = 1/72 inch)
|
|
||||||
const pageHeight = 792; // Letter size height in points
|
|
||||||
const margins = 80; // Top + bottom margins
|
|
||||||
const footerSpace = 60; // Footer space
|
|
||||||
|
|
||||||
let availableHeight = pageHeight - margins - footerSpace;
|
|
||||||
|
|
||||||
if (isFirstPage) {
|
|
||||||
// Dense header takes significant space
|
|
||||||
availableHeight -= 300; // Dense header space
|
|
||||||
} else {
|
|
||||||
// Abridged header is smaller
|
|
||||||
availableHeight -= 60; // Abridged header space
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasNotes) {
|
|
||||||
// Last page needs space for totals and notes
|
|
||||||
availableHeight -= 200; // Totals + notes space (much more conservative)
|
|
||||||
} else {
|
|
||||||
// Regular page just needs totals space
|
|
||||||
availableHeight -= 150; // Totals space only (much more conservative)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Table header takes space
|
|
||||||
availableHeight -= 30; // Table header
|
|
||||||
|
|
||||||
// Calculate how many items can fit based on actual row heights
|
|
||||||
let usedHeight = 0;
|
|
||||||
let itemCount = 0;
|
|
||||||
|
|
||||||
for (let i = startIndex; i < items.length; i++) {
|
|
||||||
const item = items[i];
|
|
||||||
if (!item) continue;
|
|
||||||
|
|
||||||
const rowHeight = calculateRowHeight(item);
|
|
||||||
|
|
||||||
if (usedHeight + rowHeight > availableHeight) {
|
|
||||||
break; // This item won't fit
|
|
||||||
}
|
|
||||||
|
|
||||||
usedHeight += rowHeight;
|
|
||||||
itemCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.max(1, itemCount); // Always return at least 1 item
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback function for backward compatibility
|
|
||||||
function calculateItemsPerPage(
|
|
||||||
isFirstPage: boolean,
|
|
||||||
hasNotes: boolean,
|
|
||||||
): number {
|
|
||||||
// Estimate available space in points (1 point = 1/72 inch)
|
|
||||||
const pageHeight = 792; // Letter size height in points
|
|
||||||
const margins = 80; // Top + bottom margins
|
|
||||||
const footerSpace = 60; // Footer space
|
|
||||||
|
|
||||||
let availableHeight = pageHeight - margins - footerSpace;
|
|
||||||
|
|
||||||
if (isFirstPage) {
|
|
||||||
// Dense header takes significant space
|
|
||||||
availableHeight -= 300; // Dense header space
|
|
||||||
} else {
|
|
||||||
// Abridged header is smaller
|
|
||||||
availableHeight -= 60; // Abridged header space
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasNotes) {
|
|
||||||
// Last page needs space for totals and notes
|
|
||||||
availableHeight -= 200; // Totals + notes space (much more conservative)
|
|
||||||
} else {
|
|
||||||
// Regular page just needs totals space
|
|
||||||
availableHeight -= 150; // Totals space only (much more conservative)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Table header takes space
|
|
||||||
availableHeight -= 30; // Table header
|
|
||||||
|
|
||||||
// Conservative estimate using average row height
|
|
||||||
const avgRowHeight = 24; // Increased from 18 to account for potential wrapping
|
|
||||||
|
|
||||||
return Math.max(1, Math.floor(availableHeight / avgRowHeight));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic pagination function
|
|
||||||
function paginateItems(
|
|
||||||
items: NonNullable<InvoiceData["items"]>,
|
|
||||||
hasNotes = false,
|
|
||||||
) {
|
|
||||||
const validItems = items.filter(Boolean);
|
|
||||||
const pages: Array<typeof validItems> = [];
|
|
||||||
|
|
||||||
if (validItems.length === 0) {
|
|
||||||
return [[]];
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentIndex = 0;
|
|
||||||
let pageIndex = 0;
|
|
||||||
|
|
||||||
while (currentIndex < validItems.length) {
|
|
||||||
const isFirstPage = pageIndex === 0;
|
|
||||||
const remainingItems = validItems.length - currentIndex;
|
|
||||||
|
|
||||||
// Determine if this could be the last page with simple calculation
|
|
||||||
const maxPossibleItems = calculateItemsPerPage(isFirstPage, false);
|
|
||||||
const wouldBeLastPage =
|
|
||||||
currentIndex + maxPossibleItems >= validItems.length;
|
|
||||||
|
|
||||||
// Calculate items per page, accounting for notes space if this is likely the last page
|
|
||||||
let itemsPerPage = calculateItemsForPage(
|
|
||||||
validItems,
|
|
||||||
currentIndex,
|
|
||||||
isFirstPage,
|
|
||||||
wouldBeLastPage && hasNotes,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fallback to conservative calculation if dynamic fails
|
|
||||||
if (itemsPerPage === 0) {
|
|
||||||
itemsPerPage = calculateItemsPerPage(
|
|
||||||
isFirstPage,
|
|
||||||
wouldBeLastPage && hasNotes,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we don't have tiny orphan pages
|
|
||||||
if (remainingItems > itemsPerPage && remainingItems - itemsPerPage < 2) {
|
|
||||||
itemsPerPage = Math.max(1, itemsPerPage - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Never take more items than we have
|
|
||||||
itemsPerPage = Math.min(itemsPerPage, remainingItems);
|
|
||||||
|
|
||||||
const pageItems = validItems.slice(
|
|
||||||
currentIndex,
|
|
||||||
currentIndex + itemsPerPage,
|
|
||||||
);
|
|
||||||
|
|
||||||
pages.push(pageItems);
|
|
||||||
currentIndex += itemsPerPage;
|
|
||||||
pageIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dense header component (first page)
|
// Dense header component (first page)
|
||||||
const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
const DenseHeader: React.FC<{
|
||||||
|
invoice: InvoiceData;
|
||||||
|
settings: Required<PDFGenerationSettings>;
|
||||||
|
}> = ({ invoice, settings }) => (
|
||||||
<View style={styles.denseHeader}>
|
<View style={styles.denseHeader}>
|
||||||
<View style={styles.headerTop}>
|
<View style={styles.headerTop}>
|
||||||
<View style={styles.businessSection}>
|
<View style={styles.businessSection}>
|
||||||
<Text style={styles.businessName}>
|
<Text style={[styles.businessName, { color: settings.pdfAccentColor }]}>
|
||||||
{invoice.business?.name ?? "Your Business Name"}
|
{invoice.business?.name ?? "Your Business Name"}
|
||||||
</Text>
|
</Text>
|
||||||
{invoice.business?.email && (
|
{invoice.business?.email && (
|
||||||
@@ -806,8 +642,13 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.invoiceSection}>
|
<View style={styles.invoiceSection}>
|
||||||
<Text style={styles.invoiceTitle}>INVOICE</Text>
|
<Text style={[styles.invoiceTitle, { color: settings.pdfAccentColor }]}>
|
||||||
<Text style={styles.invoiceNumber}>#{invoice.invoiceNumber}</Text>
|
INVOICE
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.invoiceNumber}>
|
||||||
|
{invoice.invoicePrefix ?? "#"}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
<View style={getStatusStyle(invoice.status)}>
|
<View style={getStatusStyle(invoice.status)}>
|
||||||
<Text>{getStatusLabel(invoice.status)}</Text>
|
<Text>{getStatusLabel(invoice.status)}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -865,33 +706,57 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Abridged header component (other pages)
|
|
||||||
const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
|
||||||
<View style={styles.abridgedHeader}>
|
|
||||||
<Text style={styles.abridgedBusinessName}>
|
|
||||||
{invoice.business?.name ?? "Your Business Name"}
|
|
||||||
</Text>
|
|
||||||
<View style={styles.abridgedInvoiceInfo}>
|
|
||||||
<Text style={styles.abridgedInvoiceTitle}>INVOICE</Text>
|
|
||||||
<Text style={styles.abridgedInvoiceNumber}>#{invoice.invoiceNumber}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Table header component
|
// Table header component
|
||||||
const TableHeader: React.FC = () => (
|
const TableHeader: React.FC<{
|
||||||
<View style={styles.tableHeader}>
|
settings: Required<PDFGenerationSettings>;
|
||||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderDate]}>Date</Text>
|
showRate: boolean;
|
||||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderDescription]}>
|
}> = ({ settings, showRate }) => {
|
||||||
Description
|
const cols = getColumnWidths(showRate);
|
||||||
</Text>
|
return (
|
||||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderHours]}>Hours</Text>
|
<View
|
||||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderRate]}>Rate</Text>
|
style={[
|
||||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderAmount]}>
|
styles.tableHeader,
|
||||||
Amount
|
settings.pdfTemplate === "minimal"
|
||||||
</Text>
|
? { backgroundColor: "#ffffff" }
|
||||||
</View>
|
: {},
|
||||||
);
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text>
|
||||||
|
<Text style={[styles.tableHeaderCell, { width: cols.description }]}>
|
||||||
|
Description
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tableHeaderCell,
|
||||||
|
styles.tableHeaderHours,
|
||||||
|
{ width: cols.hours },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Hours
|
||||||
|
</Text>
|
||||||
|
{showRate && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tableHeaderCell,
|
||||||
|
styles.tableHeaderRate,
|
||||||
|
{ width: cols.rate },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Rate
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tableHeaderCell,
|
||||||
|
styles.tableHeaderAmount,
|
||||||
|
{ width: cols.amount },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Amount
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Footer component
|
// Footer component
|
||||||
const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||||
@@ -907,35 +772,40 @@ const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Footer: React.FC = () => (
|
const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
|
||||||
|
settings,
|
||||||
|
}) => (
|
||||||
<View style={styles.footer} fixed>
|
<View style={styles.footer} fixed>
|
||||||
<View style={styles.footerLogo}>
|
<View style={styles.footerLogo}>
|
||||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
{settings.pdfShowLogo && (
|
||||||
<Image
|
<Image
|
||||||
src="/beenvoice-logo.png"
|
src="/beenvoice-logo.png"
|
||||||
style={{
|
style={{
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 18,
|
height: 18,
|
||||||
marginRight: 8,
|
marginRight: 8,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Helvetica",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
marginLeft: 8,
|
marginLeft: settings.pdfShowLogo ? 8 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Professional Invoicing
|
{settings.pdfFooterText}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
{settings.pdfShowPageNumbers && (
|
||||||
style={styles.pageNumber}
|
<Text
|
||||||
render={({ pageNumber, totalPages }) =>
|
style={styles.pageNumber}
|
||||||
`Page ${pageNumber} of ${totalPages}`
|
render={({ pageNumber, totalPages }) =>
|
||||||
}
|
`Page ${pageNumber} of ${totalPages}`
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -943,13 +813,27 @@ const Footer: React.FC = () => (
|
|||||||
const TotalsSection: React.FC<{
|
const TotalsSection: React.FC<{
|
||||||
invoice: InvoiceData;
|
invoice: InvoiceData;
|
||||||
items: Array<NonNullable<InvoiceData["items"]>[0]>;
|
items: Array<NonNullable<InvoiceData["items"]>[0]>;
|
||||||
}> = ({ invoice, items }) => {
|
settings: Required<PDFGenerationSettings>;
|
||||||
|
}> = ({ invoice, items, settings }) => {
|
||||||
|
const currency = invoice.currency ?? "USD";
|
||||||
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
|
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
|
||||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||||
|
const total = subtotal + taxAmount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.totalsContainer}>
|
<View style={styles.totalsContainer}>
|
||||||
<View style={styles.totalsBox}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.totalsBox,
|
||||||
|
settings.pdfTemplate === "minimal"
|
||||||
|
? {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderTop: "1px solid #e5e7eb",
|
||||||
|
paddingHorizontal: 0,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -965,20 +849,29 @@ const TotalsSection: React.FC<{
|
|||||||
|
|
||||||
<View style={styles.totalRow}>
|
<View style={styles.totalRow}>
|
||||||
<Text style={styles.totalLabel}>Subtotal:</Text>
|
<Text style={styles.totalLabel}>Subtotal:</Text>
|
||||||
<Text style={styles.totalAmount}>{formatCurrency(subtotal)}</Text>
|
<Text style={styles.totalAmount}>
|
||||||
|
{formatCurrency(subtotal, currency)}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{invoice.taxRate > 0 && (
|
{invoice.taxRate > 0 && (
|
||||||
<View style={styles.totalRow}>
|
<View style={styles.totalRow}>
|
||||||
<Text style={styles.totalLabel}>Tax ({invoice.taxRate}%):</Text>
|
<Text style={styles.totalLabel}>Tax ({invoice.taxRate}%):</Text>
|
||||||
<Text style={styles.totalAmount}>{formatCurrency(taxAmount)}</Text>
|
<Text style={styles.totalAmount}>
|
||||||
|
{formatCurrency(taxAmount, currency)}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.finalTotalRow}>
|
<View style={styles.finalTotalRow}>
|
||||||
<Text style={styles.finalTotalLabel}>TOTAL:</Text>
|
<Text style={styles.finalTotalLabel}>TOTAL:</Text>
|
||||||
<Text style={styles.finalTotalAmount}>
|
<Text
|
||||||
{formatCurrency(invoice.totalAmount)}
|
style={[
|
||||||
|
styles.finalTotalAmount,
|
||||||
|
{ color: settings.pdfAccentColor },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{formatCurrency(total, currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -991,87 +884,106 @@ const TotalsSection: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Main PDF component
|
// Main PDF component
|
||||||
const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
export const InvoicePDF: React.FC<{
|
||||||
|
invoice: InvoiceData;
|
||||||
|
settings?: PDFGenerationSettings;
|
||||||
|
}> = ({ invoice, settings: inputSettings }) => {
|
||||||
|
const settings = resolvePDFSettings(inputSettings);
|
||||||
const items = invoice.items?.filter(Boolean) ?? [];
|
const items = invoice.items?.filter(Boolean) ?? [];
|
||||||
const paginatedItems = paginateItems(items, Boolean(invoice.notes));
|
const currency = invoice.currency ?? "USD";
|
||||||
|
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
|
||||||
|
const cols = getColumnWidths(showRate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Document>
|
<Document>
|
||||||
{paginatedItems.map((pageItems, pageIndex) => {
|
<Page size="LETTER" style={styles.page}>
|
||||||
const isFirstPage = pageIndex === 0;
|
<DenseHeader invoice={invoice} settings={settings} />
|
||||||
const isLastPage = pageIndex === paginatedItems.length - 1;
|
|
||||||
const hasItems = pageItems.length > 0;
|
|
||||||
|
|
||||||
return (
|
{items.length > 0 && (
|
||||||
<Page key={`page-${pageIndex}`} size="LETTER" style={styles.page}>
|
<View style={styles.tableContainer}>
|
||||||
{/* Header */}
|
<TableHeader settings={settings} showRate={showRate} />
|
||||||
{isFirstPage ? (
|
{items.map(
|
||||||
<DenseHeader invoice={invoice} />
|
(item, index) =>
|
||||||
) : (
|
item && (
|
||||||
<AbridgedHeader invoice={invoice} />
|
<View
|
||||||
)}
|
key={`invoice-item-${index}`}
|
||||||
|
wrap={false}
|
||||||
{/* Table */}
|
style={[
|
||||||
{hasItems && (
|
styles.tableRow,
|
||||||
<View style={styles.tableContainer}>
|
settings.pdfTemplate === "classic" && index % 2 === 0
|
||||||
<TableHeader />
|
? styles.tableRowAlt
|
||||||
{pageItems.map(
|
: {},
|
||||||
(item, index) =>
|
]}
|
||||||
item && (
|
>
|
||||||
<View
|
<Text
|
||||||
key={`${pageIndex}-${index}`}
|
style={[
|
||||||
|
styles.tableCell,
|
||||||
|
styles.tableCellDate,
|
||||||
|
{ width: cols.date },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{formatDate(item.date)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tableCell,
|
||||||
|
styles.tableCellDescription,
|
||||||
|
{ width: cols.description },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tableCell,
|
||||||
|
styles.tableCellHours,
|
||||||
|
{ width: cols.hours },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.hours}
|
||||||
|
</Text>
|
||||||
|
{showRate && (
|
||||||
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tableRow,
|
styles.tableCell,
|
||||||
index % 2 === 0 ? styles.tableRowAlt : {},
|
styles.tableCellRate,
|
||||||
|
{ width: cols.rate },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.tableCell, styles.tableCellDate]}>
|
{formatCurrency(item.rate, currency)}
|
||||||
{formatDate(item.date)}
|
</Text>
|
||||||
</Text>
|
)}
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tableCell,
|
styles.tableCell,
|
||||||
styles.tableCellDescription,
|
styles.tableCellAmount,
|
||||||
]}
|
{ width: cols.amount },
|
||||||
>
|
]}
|
||||||
{item.description}
|
>
|
||||||
</Text>
|
{formatCurrency(item.amount, currency)}
|
||||||
<Text style={[styles.tableCell, styles.tableCellHours]}>
|
</Text>
|
||||||
{item.hours}
|
</View>
|
||||||
</Text>
|
),
|
||||||
<Text style={[styles.tableCell, styles.tableCellRate]}>
|
|
||||||
{formatCurrency(item.rate)}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[styles.tableCell, styles.tableCellAmount]}
|
|
||||||
>
|
|
||||||
{formatCurrency(item.amount)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bottom section with notes and totals (only on last page) */}
|
<View style={styles.bottomSection} wrap={false}>
|
||||||
{isLastPage && (
|
{invoice.notes && <NotesSection invoice={invoice} />}
|
||||||
<View style={styles.bottomSection}>
|
<TotalsSection invoice={invoice} items={items} settings={settings} />
|
||||||
{invoice.notes && <NotesSection invoice={invoice} />}
|
</View>
|
||||||
<TotalsSection invoice={invoice} items={items} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer */}
|
<Footer settings={settings} />
|
||||||
<Footer />
|
</Page>
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Document>
|
</Document>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export functions
|
// Export functions
|
||||||
export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
export async function generateInvoicePDF(
|
||||||
|
invoice: InvoiceData,
|
||||||
|
settings?: PDFGenerationSettings,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Validate invoice data
|
// Validate invoice data
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
@@ -1087,7 +999,9 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate PDF blob
|
// Generate PDF blob
|
||||||
const originalBlob = await pdf(<InvoicePDF invoice={invoice} />).toBlob();
|
const originalBlob = await pdf(
|
||||||
|
<InvoicePDF invoice={invoice} settings={settings} />,
|
||||||
|
).toBlob();
|
||||||
|
|
||||||
// Validate blob
|
// Validate blob
|
||||||
if (!originalBlob || originalBlob.size === 0) {
|
if (!originalBlob || originalBlob.size === 0) {
|
||||||
@@ -1113,6 +1027,7 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
|||||||
// Additional utility function for generating PDF without downloading
|
// Additional utility function for generating PDF without downloading
|
||||||
export async function generateInvoicePDFBlob(
|
export async function generateInvoicePDFBlob(
|
||||||
invoice: InvoiceData,
|
invoice: InvoiceData,
|
||||||
|
settings?: PDFGenerationSettings,
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
try {
|
try {
|
||||||
// Validate invoice data
|
// Validate invoice data
|
||||||
@@ -1129,7 +1044,9 @@ export async function generateInvoicePDFBlob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate PDF blob
|
// Generate PDF blob
|
||||||
const originalBlob = await pdf(<InvoicePDF invoice={invoice} />).toBlob();
|
const originalBlob = await pdf(
|
||||||
|
<InvoicePDF invoice={invoice} settings={settings} />,
|
||||||
|
).toBlob();
|
||||||
|
|
||||||
// Validate blob
|
// Validate blob
|
||||||
if (!originalBlob || originalBlob.size === 0) {
|
if (!originalBlob || originalBlob.size === 0) {
|
||||||
|
|||||||
@@ -1,14 +1,37 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
import { invoices } from "~/server/db/schema";
|
import { invoices, platformSettings } from "~/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||||
|
|
||||||
// Default Resend instance - will be overridden if business has custom API key
|
function plainTextToHtml(value: string) {
|
||||||
const defaultResend = new Resend(env.RESEND_API_KEY);
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmailNoteHtml(value: string) {
|
||||||
|
const visibleText = value
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/p>/gi, "\n")
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/ |\u00a0/g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return visibleText ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
export const emailRouter = createTRPCRouter({
|
export const emailRouter = createTRPCRouter({
|
||||||
sendInvoice: protectedProcedure
|
sendInvoice: protectedProcedure
|
||||||
@@ -56,7 +79,19 @@ export const emailRouter = createTRPCRouter({
|
|||||||
// Generate PDF for attachment
|
// Generate PDF for attachment
|
||||||
let pdfBuffer: Buffer;
|
let pdfBuffer: Buffer;
|
||||||
try {
|
try {
|
||||||
const pdfBlob = await generateInvoicePDFBlob(invoice);
|
const settings = await ctx.db.query.platformSettings.findFirst({
|
||||||
|
where: eq(platformSettings.id, "global"),
|
||||||
|
});
|
||||||
|
const pdfBlob = await generateInvoicePDFBlob(invoice, {
|
||||||
|
pdfTemplate: settings?.pdfTemplate as
|
||||||
|
| "classic"
|
||||||
|
| "minimal"
|
||||||
|
| undefined,
|
||||||
|
pdfAccentColor: settings?.pdfAccentColor,
|
||||||
|
pdfFooterText: settings?.pdfFooterText,
|
||||||
|
pdfShowLogo: settings?.pdfShowLogo,
|
||||||
|
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||||
|
});
|
||||||
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
|
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
|
||||||
|
|
||||||
// Validate PDF was generated successfully
|
// Validate PDF was generated successfully
|
||||||
@@ -86,6 +121,12 @@ export const emailRouter = createTRPCRouter({
|
|||||||
"Your Name";
|
"Your Name";
|
||||||
const userEmail =
|
const userEmail =
|
||||||
invoice.business?.email ?? ctx.session.user?.email ?? "";
|
invoice.business?.email ?? ctx.session.user?.email ?? "";
|
||||||
|
const customMessage =
|
||||||
|
input.customMessage !== undefined
|
||||||
|
? normalizeEmailNoteHtml(input.customMessage)
|
||||||
|
: invoice.emailMessage
|
||||||
|
? plainTextToHtml(invoice.emailMessage)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Generate branded email template
|
// Generate branded email template
|
||||||
const emailTemplate = generateInvoiceEmailTemplate({
|
const emailTemplate = generateInvoiceEmailTemplate({
|
||||||
@@ -96,7 +137,7 @@ export const emailRouter = createTRPCRouter({
|
|||||||
status: invoice.status,
|
status: invoice.status,
|
||||||
totalAmount: invoice.totalAmount,
|
totalAmount: invoice.totalAmount,
|
||||||
taxRate: invoice.taxRate,
|
taxRate: invoice.taxRate,
|
||||||
notes: invoice.notes,
|
currency: invoice.currency,
|
||||||
client: {
|
client: {
|
||||||
name: invoice.client.name,
|
name: invoice.client.name,
|
||||||
email: invoice.client.email,
|
email: invoice.client.email,
|
||||||
@@ -105,7 +146,7 @@ export const emailRouter = createTRPCRouter({
|
|||||||
items: invoice.items,
|
items: invoice.items,
|
||||||
},
|
},
|
||||||
customContent: input.customContent,
|
customContent: input.customContent,
|
||||||
customMessage: input.customMessage,
|
customMessage,
|
||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
||||||
@@ -126,14 +167,17 @@ export const emailRouter = createTRPCRouter({
|
|||||||
: invoice.business.name) ??
|
: invoice.business.name) ??
|
||||||
userName;
|
userName;
|
||||||
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
|
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
|
||||||
} else if (env.RESEND_DOMAIN) {
|
} else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) {
|
||||||
// Use system Resend configuration
|
// Use system Resend configuration
|
||||||
resendInstance = defaultResend;
|
resendInstance = new Resend(env.RESEND_API_KEY);
|
||||||
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
|
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
|
||||||
|
} else if (env.RESEND_API_KEY) {
|
||||||
|
resendInstance = new Resend(env.RESEND_API_KEY);
|
||||||
|
fromEmail = invoice.business?.email ?? "noreply@example.com";
|
||||||
} else {
|
} else {
|
||||||
// Fallback to business email if no configured domains
|
throw new Error(
|
||||||
resendInstance = defaultResend;
|
"Email delivery is not configured. Add a Resend API key globally or on this business.",
|
||||||
fromEmail = invoice.business?.email ?? "noreply@yourdomain.com";
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare CC and BCC lists
|
// Prepare CC and BCC lists
|
||||||
|
|||||||
@@ -3,18 +3,9 @@ import { eq, and, desc } from "drizzle-orm";
|
|||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { expenses, clients, businesses, invoices } from "~/server/db/schema";
|
import { expenses, clients, businesses, invoices } from "~/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||||
|
|
||||||
export const EXPENSE_CATEGORIES = [
|
export { EXPENSE_CATEGORIES };
|
||||||
"Travel",
|
|
||||||
"Meals & Entertainment",
|
|
||||||
"Software & Subscriptions",
|
|
||||||
"Hardware & Equipment",
|
|
||||||
"Office Supplies",
|
|
||||||
"Marketing",
|
|
||||||
"Professional Services",
|
|
||||||
"Utilities",
|
|
||||||
"Other",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const createExpenseSchema = z.object({
|
const createExpenseSchema = z.object({
|
||||||
date: z.date(),
|
date: z.date(),
|
||||||
@@ -24,6 +15,7 @@ const createExpenseSchema = z.object({
|
|||||||
category: z.string().optional().or(z.literal("")),
|
category: z.string().optional().or(z.literal("")),
|
||||||
billable: z.boolean().default(false),
|
billable: z.boolean().default(false),
|
||||||
reimbursable: z.boolean().default(false),
|
reimbursable: z.boolean().default(false),
|
||||||
|
taxDeductible: z.boolean().default(false),
|
||||||
notes: z.string().optional().or(z.literal("")),
|
notes: z.string().optional().or(z.literal("")),
|
||||||
clientId: z.string().optional().or(z.literal("")),
|
clientId: z.string().optional().or(z.literal("")),
|
||||||
businessId: z.string().optional().or(z.literal("")),
|
businessId: z.string().optional().or(z.literal("")),
|
||||||
|
|||||||
+273
-164
@@ -6,8 +6,16 @@ import {
|
|||||||
invoiceItems,
|
invoiceItems,
|
||||||
clients,
|
clients,
|
||||||
businesses,
|
businesses,
|
||||||
|
platformSettings,
|
||||||
} from "~/server/db/schema";
|
} from "~/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||||
|
import type { db } from "~/server/db";
|
||||||
|
|
||||||
|
type InvoiceRouterContext = {
|
||||||
|
db: typeof db;
|
||||||
|
session: { user: { id: string } };
|
||||||
|
};
|
||||||
|
|
||||||
const invoiceItemSchema = z.object({
|
const invoiceItemSchema = z.object({
|
||||||
date: z.date(),
|
date: z.date(),
|
||||||
@@ -18,6 +26,7 @@ const invoiceItemSchema = z.object({
|
|||||||
|
|
||||||
const createInvoiceSchema = z.object({
|
const createInvoiceSchema = z.object({
|
||||||
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
||||||
|
invoicePrefix: z.string().optional().default("#"),
|
||||||
businessId: z
|
businessId: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "Business is required")
|
.min(1, "Business is required")
|
||||||
@@ -28,6 +37,7 @@ const createInvoiceSchema = z.object({
|
|||||||
dueDate: z.date(),
|
dueDate: z.date(),
|
||||||
status: z.enum(["draft", "sent", "paid"]).default("draft"),
|
status: z.enum(["draft", "sent", "paid"]).default("draft"),
|
||||||
notes: z.string().optional().or(z.literal("")),
|
notes: z.string().optional().or(z.literal("")),
|
||||||
|
emailMessage: z.string().optional().or(z.literal("")),
|
||||||
taxRate: z.number().min(0).max(100).default(0),
|
taxRate: z.number().min(0).max(100).default(0),
|
||||||
currency: z.string().length(3).default("USD"),
|
currency: z.string().length(3).default("USD"),
|
||||||
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
||||||
@@ -42,6 +52,64 @@ const updateStatusSchema = z.object({
|
|||||||
status: z.enum(["draft", "sent", "paid"]),
|
status: z.enum(["draft", "sent", "paid"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function verifyBusinessAccess(
|
||||||
|
ctx: InvoiceRouterContext,
|
||||||
|
businessId?: string | null,
|
||||||
|
) {
|
||||||
|
if (!businessId) return null;
|
||||||
|
|
||||||
|
const business = await ctx.db.query.businesses.findFirst({
|
||||||
|
where: eq(businesses.id, businessId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Business not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (business.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You don't have permission to use this business",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return business;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyClientAccess(ctx: InvoiceRouterContext, clientId: string) {
|
||||||
|
const client = await ctx.db.query.clients.findFirst({
|
||||||
|
where: eq(clients.id, clientId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Client not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You don't have permission to use this client",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateInvoiceTotal = (
|
||||||
|
items: Array<z.infer<typeof invoiceItemSchema>>,
|
||||||
|
taxRate: number,
|
||||||
|
) => {
|
||||||
|
const subtotal = items.reduce((sum, item) => sum + item.hours * item.rate, 0);
|
||||||
|
const taxAmount = (subtotal * taxRate) / 100;
|
||||||
|
return subtotal + taxAmount;
|
||||||
|
};
|
||||||
|
|
||||||
export const invoicesRouter = createTRPCRouter({
|
export const invoicesRouter = createTRPCRouter({
|
||||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||||
try {
|
try {
|
||||||
@@ -139,85 +207,56 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
try {
|
try {
|
||||||
const { items, ...invoiceData } = input;
|
const { items, ...invoiceData } = input;
|
||||||
|
const cleanInvoiceData = {
|
||||||
|
...invoiceData,
|
||||||
|
businessId:
|
||||||
|
!invoiceData.businessId || invoiceData.businessId.trim() === ""
|
||||||
|
? null
|
||||||
|
: invoiceData.businessId,
|
||||||
|
notes: invoiceData.notes === "" ? null : invoiceData.notes,
|
||||||
|
emailMessage:
|
||||||
|
invoiceData.emailMessage === "" ? null : invoiceData.emailMessage,
|
||||||
|
};
|
||||||
|
|
||||||
// Verify business exists and belongs to user (if provided)
|
// Verify business exists and belongs to user (if provided)
|
||||||
if (invoiceData.businessId && invoiceData.businessId.trim() !== "") {
|
await verifyBusinessAccess(ctx, cleanInvoiceData.businessId);
|
||||||
const business = await ctx.db.query.businesses.findFirst({
|
|
||||||
where: eq(businesses.id, invoiceData.businessId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!business) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Business not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (business.createdById !== ctx.session.user.id) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message:
|
|
||||||
"You don't have permission to create invoices for this business",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify client exists and belongs to user
|
// Verify client exists and belongs to user
|
||||||
const client = await ctx.db.query.clients.findFirst({
|
await verifyClientAccess(ctx, cleanInvoiceData.clientId);
|
||||||
where: eq(clients.id, invoiceData.clientId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!client) {
|
const totalAmount = calculateInvoiceTotal(
|
||||||
throw new TRPCError({
|
items,
|
||||||
code: "BAD_REQUEST",
|
cleanInvoiceData.taxRate,
|
||||||
message: "Client not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (client.createdById !== ctx.session.user.id) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message:
|
|
||||||
"You don't have permission to create invoices for this client",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate subtotal and tax
|
|
||||||
const subtotal = items.reduce(
|
|
||||||
(sum, item) => sum + item.hours * item.rate,
|
|
||||||
0,
|
|
||||||
);
|
);
|
||||||
const taxAmount = (subtotal * invoiceData.taxRate) / 100;
|
|
||||||
const totalAmount = subtotal + taxAmount;
|
|
||||||
|
|
||||||
// Create invoice
|
return await ctx.db.transaction(async (tx) => {
|
||||||
const [invoice] = await ctx.db
|
const [invoice] = await tx
|
||||||
.insert(invoices)
|
.insert(invoices)
|
||||||
.values({
|
.values({
|
||||||
...invoiceData,
|
...cleanInvoiceData,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
createdById: ctx.session.user.id,
|
createdById: ctx.session.user.id,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message: "Failed to create invoice",
|
message: "Failed to create invoice",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create invoice items
|
await tx.insert(invoiceItems).values(
|
||||||
const itemsToInsert = items.map((item, idx) => ({
|
items.map((item, idx) => ({
|
||||||
...item,
|
...item,
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
amount: item.hours * item.rate,
|
amount: item.hours * item.rate,
|
||||||
position: idx,
|
position: idx,
|
||||||
}));
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
return invoice;
|
||||||
|
});
|
||||||
return invoice;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TRPCError) throw error;
|
if (error instanceof TRPCError) throw error;
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -237,11 +276,25 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
// Clean up empty strings to null for optional string fields only
|
// Clean up empty strings to null for optional string fields only
|
||||||
const cleanInvoiceData = {
|
const cleanInvoiceData = {
|
||||||
...invoiceData,
|
...invoiceData,
|
||||||
businessId:
|
...(invoiceData.businessId !== undefined
|
||||||
!invoiceData.businessId || invoiceData.businessId.trim() === ""
|
? {
|
||||||
? null
|
businessId:
|
||||||
: invoiceData.businessId,
|
invoiceData.businessId.trim() === ""
|
||||||
notes: invoiceData.notes === "" ? null : invoiceData.notes,
|
? null
|
||||||
|
: invoiceData.businessId,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(invoiceData.notes !== undefined
|
||||||
|
? { notes: invoiceData.notes === "" ? null : invoiceData.notes }
|
||||||
|
: {}),
|
||||||
|
...(invoiceData.emailMessage !== undefined
|
||||||
|
? {
|
||||||
|
emailMessage:
|
||||||
|
invoiceData.emailMessage === ""
|
||||||
|
? null
|
||||||
|
: invoiceData.emailMessage,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Verify invoice exists and belongs to user
|
// Verify invoice exists and belongs to user
|
||||||
@@ -268,96 +321,66 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
cleanInvoiceData.businessId &&
|
cleanInvoiceData.businessId &&
|
||||||
cleanInvoiceData.businessId.trim() !== ""
|
cleanInvoiceData.businessId.trim() !== ""
|
||||||
) {
|
) {
|
||||||
const business = await ctx.db.query.businesses.findFirst({
|
await verifyBusinessAccess(ctx, cleanInvoiceData.businessId);
|
||||||
where: eq(businesses.id, cleanInvoiceData.businessId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!business || business.createdById !== ctx.session.user.id) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "You don't have permission to use this business",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If client is being updated, verify it belongs to user
|
// If client is being updated, verify it belongs to user
|
||||||
if (cleanInvoiceData.clientId) {
|
if (cleanInvoiceData.clientId) {
|
||||||
const client = await ctx.db.query.clients.findFirst({
|
await verifyClientAccess(ctx, cleanInvoiceData.clientId);
|
||||||
where: eq(clients.id, cleanInvoiceData.clientId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!client || client.createdById !== ctx.session.user.id) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "You don't have permission to use this client",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items) {
|
await ctx.db.transaction(async (tx) => {
|
||||||
// Calculate subtotal and tax
|
if (items) {
|
||||||
const subtotal = items.reduce(
|
const totalAmount = calculateInvoiceTotal(
|
||||||
(sum, item) => sum + item.hours * item.rate,
|
items,
|
||||||
0,
|
cleanInvoiceData.taxRate ?? existingInvoice.taxRate,
|
||||||
);
|
);
|
||||||
const taxAmount =
|
|
||||||
(subtotal * (cleanInvoiceData.taxRate ?? existingInvoice.taxRate)) /
|
|
||||||
100;
|
|
||||||
const totalAmount = subtotal + taxAmount;
|
|
||||||
|
|
||||||
// Update invoice
|
const [updatedInvoice] = await tx
|
||||||
const updateData = {
|
.update(invoices)
|
||||||
...cleanInvoiceData,
|
.set({
|
||||||
totalAmount,
|
...cleanInvoiceData,
|
||||||
updatedAt: new Date(),
|
totalAmount,
|
||||||
};
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(invoices.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
const [updatedInvoice] = await ctx.db
|
if (!updatedInvoice) {
|
||||||
.update(invoices)
|
throw new TRPCError({
|
||||||
.set(updateData)
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
.where(eq(invoices.id, id))
|
message: "Failed to update invoice",
|
||||||
.returning();
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!updatedInvoice) {
|
await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
await tx.insert(invoiceItems).values(
|
||||||
message: "Failed to update invoice",
|
items.map((item, idx) => ({
|
||||||
});
|
...item,
|
||||||
|
invoiceId: id,
|
||||||
|
amount: item.hours * item.rate,
|
||||||
|
position: idx,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const [updatedInvoice] = await tx
|
||||||
|
.update(invoices)
|
||||||
|
.set({
|
||||||
|
...cleanInvoiceData,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(invoices.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updatedInvoice) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to update invoice",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// Delete existing items and create new ones
|
|
||||||
await ctx.db
|
|
||||||
.delete(invoiceItems)
|
|
||||||
.where(eq(invoiceItems.invoiceId, id));
|
|
||||||
|
|
||||||
const itemsToInsert = items.map((item, idx) => ({
|
|
||||||
...item,
|
|
||||||
invoiceId: id,
|
|
||||||
amount: item.hours * item.rate,
|
|
||||||
position: idx,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
|
||||||
} else {
|
|
||||||
// Update invoice without items
|
|
||||||
const updateData = {
|
|
||||||
...cleanInvoiceData,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const [updatedInvoice] = await ctx.db
|
|
||||||
.update(invoices)
|
|
||||||
.set(updateData)
|
|
||||||
.where(eq(invoices.id, id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!updatedInvoice) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: "Failed to update invoice",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -416,11 +439,17 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" });
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Invoice not found",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invoice.createdById !== ctx.session.user.id) {
|
if (invoice.createdById !== ctx.session.user.id) {
|
||||||
throw new TRPCError({ code: "FORBIDDEN", message: "You don't have permission to update this invoice" });
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You don't have permission to update this invoice",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
@@ -428,18 +457,27 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
.set({ status: input.status, updatedAt: new Date() })
|
.set({ status: input.status, updatedAt: new Date() })
|
||||||
.where(eq(invoices.id, input.id));
|
.where(eq(invoices.id, input.id));
|
||||||
|
|
||||||
return { success: true, message: `Invoice status updated to ${input.status}` };
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Invoice status updated to ${input.status}`,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TRPCError) throw error;
|
if (error instanceof TRPCError) throw error;
|
||||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to update invoice status", cause: error });
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to update invoice status",
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
bulkUpdateStatus: protectedProcedure
|
bulkUpdateStatus: protectedProcedure
|
||||||
.input(z.object({
|
.input(
|
||||||
ids: z.array(z.string()).min(1),
|
z.object({
|
||||||
status: z.enum(["draft", "sent", "paid"]),
|
ids: z.array(z.string()).min(1),
|
||||||
}))
|
status: z.enum(["draft", "sent", "paid"]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// Only update invoices owned by this user
|
// Only update invoices owned by this user
|
||||||
const owned = await ctx.db.query.invoices.findMany({
|
const owned = await ctx.db.query.invoices.findMany({
|
||||||
@@ -452,7 +490,10 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
.map((inv) => inv.id);
|
.map((inv) => inv.id);
|
||||||
|
|
||||||
if (ownedIds.length === 0) {
|
if (ownedIds.length === 0) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" });
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "No matching invoices found",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
@@ -476,11 +517,79 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
.map((inv) => inv.id);
|
.map((inv) => inv.id);
|
||||||
|
|
||||||
if (ownedIds.length === 0) {
|
if (ownedIds.length === 0) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" });
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "No matching invoices found",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.delete(invoices).where(inArray(invoices.id, ownedIds));
|
await ctx.db.delete(invoices).where(inArray(invoices.id, ownedIds));
|
||||||
|
|
||||||
return { success: true, deleted: ownedIds.length };
|
return { success: true, deleted: ownedIds.length };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
previewPdf: protectedProcedure
|
||||||
|
.input(createInvoiceSchema)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
try {
|
||||||
|
const businessId =
|
||||||
|
input.businessId && input.businessId.trim() !== ""
|
||||||
|
? input.businessId
|
||||||
|
: null;
|
||||||
|
const [client, business, settings] = await Promise.all([
|
||||||
|
verifyClientAccess(ctx, input.clientId),
|
||||||
|
verifyBusinessAccess(ctx, businessId),
|
||||||
|
ctx.db.query.platformSettings.findFirst({
|
||||||
|
where: eq(platformSettings.id, "global"),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalAmount = calculateInvoiceTotal(input.items, input.taxRate);
|
||||||
|
const pdfBlob = await generateInvoicePDFBlob(
|
||||||
|
{
|
||||||
|
invoiceNumber: input.invoiceNumber,
|
||||||
|
invoicePrefix: input.invoicePrefix,
|
||||||
|
issueDate: input.issueDate,
|
||||||
|
dueDate: input.dueDate,
|
||||||
|
status: input.status,
|
||||||
|
totalAmount,
|
||||||
|
taxRate: input.taxRate,
|
||||||
|
currency: input.currency,
|
||||||
|
notes: input.notes,
|
||||||
|
client,
|
||||||
|
business,
|
||||||
|
items: input.items.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
description: item.description,
|
||||||
|
hours: item.hours,
|
||||||
|
rate: item.rate,
|
||||||
|
amount: item.hours * item.rate,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pdfTemplate: settings?.pdfTemplate as
|
||||||
|
| "classic"
|
||||||
|
| "minimal"
|
||||||
|
| undefined,
|
||||||
|
pdfAccentColor: settings?.pdfAccentColor,
|
||||||
|
pdfFooterText: settings?.pdfFooterText,
|
||||||
|
pdfShowLogo: settings?.pdfShowLogo,
|
||||||
|
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await pdfBlob.arrayBuffer());
|
||||||
|
return {
|
||||||
|
contentType: "application/pdf",
|
||||||
|
base64: buffer.toString("base64"),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TRPCError) throw error;
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to generate PDF preview",
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,48 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc";
|
import {
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
publicProcedure,
|
||||||
|
} from "~/server/api/trpc";
|
||||||
import {
|
import {
|
||||||
users,
|
users,
|
||||||
clients,
|
clients,
|
||||||
businesses,
|
businesses,
|
||||||
invoices,
|
invoices,
|
||||||
invoiceItems,
|
invoiceItems,
|
||||||
|
platformSettings,
|
||||||
} from "~/server/db/schema";
|
} from "~/server/db/schema";
|
||||||
|
import {
|
||||||
|
defaultBodyFontPreference,
|
||||||
|
defaultFontPreference,
|
||||||
|
defaultHeadingFontPreference,
|
||||||
|
defaultInterfaceTheme,
|
||||||
|
defaultRadiusPreference,
|
||||||
|
defaultSidebarStyle,
|
||||||
|
type ColorMode,
|
||||||
|
type ColorTheme,
|
||||||
|
type FontPreference,
|
||||||
|
type InterfaceTheme,
|
||||||
|
type RadiusPreference,
|
||||||
|
type SidebarStyle,
|
||||||
|
} from "~/lib/branding";
|
||||||
|
|
||||||
|
async function requireAdmin(ctx: {
|
||||||
|
db: typeof import("~/server/db").db;
|
||||||
|
session: { user: { id: string } };
|
||||||
|
}) {
|
||||||
|
const user = await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, ctx.session.user.id),
|
||||||
|
columns: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user?.role !== "admin") {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validation schemas for backup data
|
// Validation schemas for backup data
|
||||||
const ClientBackupSchema = z.object({
|
const ClientBackupSchema = z.object({
|
||||||
@@ -60,6 +94,7 @@ const InvoiceBackupSchema = z.object({
|
|||||||
totalAmount: z.number().default(0),
|
totalAmount: z.number().default(0),
|
||||||
taxRate: z.number().default(0),
|
taxRate: z.number().default(0),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
emailMessage: z.string().optional(),
|
||||||
items: z.array(InvoiceItemBackupSchema),
|
items: z.array(InvoiceItemBackupSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,6 +111,37 @@ const BackupDataSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const settingsRouter = createTRPCRouter({
|
export const settingsRouter = createTRPCRouter({
|
||||||
|
listAccounts: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
await requireAdmin(ctx);
|
||||||
|
return ctx.db.query.users.findMany({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
emailVerified: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: (users, { asc }) => [asc(users.createdAt)],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateAccountRole: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
role: z.enum(["user", "admin"]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await requireAdmin(ctx);
|
||||||
|
await ctx.db
|
||||||
|
.update(users)
|
||||||
|
.set({ role: input.role })
|
||||||
|
.where(eq(users.id, input.userId));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
// Get user profile information
|
// Get user profile information
|
||||||
getProfile: protectedProcedure.query(async ({ ctx }) => {
|
getProfile: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const user = await ctx.db.query.users.findFirst({
|
const user = await ctx.db.query.users.findFirst({
|
||||||
@@ -85,6 +151,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
image: true,
|
image: true,
|
||||||
|
role: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,20 +211,41 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// Get theme preferences
|
// Get theme preferences
|
||||||
getTheme: protectedProcedure.query(async ({ ctx }) => {
|
getTheme: publicProcedure.query(async ({ ctx }) => {
|
||||||
const user = await ctx.db.query.users.findFirst({
|
const settings = await ctx.db.query.platformSettings.findFirst({
|
||||||
where: eq(users.id, ctx.session.user.id),
|
where: eq(platformSettings.id, "global"),
|
||||||
columns: {
|
|
||||||
colorTheme: true,
|
|
||||||
customColor: true,
|
|
||||||
theme: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
colorTheme: (user?.colorTheme as "slate" | "blue" | "green" | "rose" | "orange" | "custom") ?? "slate",
|
colorTheme: (settings?.colorTheme as ColorTheme) ?? "slate",
|
||||||
customColor: user?.customColor ?? undefined,
|
customColor: settings?.customColor ?? undefined,
|
||||||
theme: (user?.theme as "light" | "dark" | "system") ?? "system",
|
theme: (settings?.theme as ColorMode) ?? "system",
|
||||||
|
interfaceTheme:
|
||||||
|
(settings?.interfaceTheme as InterfaceTheme) ?? defaultInterfaceTheme,
|
||||||
|
fontPreference: defaultFontPreference,
|
||||||
|
bodyFontPreference:
|
||||||
|
(settings?.bodyFontPreference as FontPreference) ??
|
||||||
|
defaultBodyFontPreference,
|
||||||
|
headingFontPreference:
|
||||||
|
(settings?.headingFontPreference as FontPreference) ??
|
||||||
|
defaultHeadingFontPreference,
|
||||||
|
radiusPreference:
|
||||||
|
(settings?.radiusPreference as RadiusPreference) ??
|
||||||
|
defaultRadiusPreference,
|
||||||
|
sidebarStyle:
|
||||||
|
(settings?.sidebarStyle as SidebarStyle) ?? defaultSidebarStyle,
|
||||||
|
brandName: settings?.brandName ?? "beenvoice",
|
||||||
|
brandTagline:
|
||||||
|
settings?.brandTagline ??
|
||||||
|
"Simple and efficient invoicing for freelancers and small businesses",
|
||||||
|
brandLogoText: settings?.brandLogoText ?? "beenvoice",
|
||||||
|
brandIcon: settings?.brandIcon ?? "$",
|
||||||
|
pdfTemplate:
|
||||||
|
(settings?.pdfTemplate as "classic" | "minimal") ?? "classic",
|
||||||
|
pdfAccentColor: settings?.pdfAccentColor ?? "#111827",
|
||||||
|
pdfFooterText: settings?.pdfFooterText ?? "Professional Invoicing",
|
||||||
|
pdfShowLogo: settings?.pdfShowLogo ?? true,
|
||||||
|
pdfShowPageNumbers: settings?.pdfShowPageNumbers ?? true,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -165,20 +253,105 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
updateTheme: protectedProcedure
|
updateTheme: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
colorTheme: z.enum(["slate", "blue", "green", "rose", "orange", "custom"]).optional(),
|
colorTheme: z
|
||||||
|
.enum(["slate", "blue", "green", "rose", "orange", "custom"])
|
||||||
|
.optional(),
|
||||||
customColor: z.string().optional(),
|
customColor: z.string().optional(),
|
||||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||||
|
interfaceTheme: z
|
||||||
|
.enum(["beenvoice", "shadcn", "minimal", "editorial"])
|
||||||
|
.optional(),
|
||||||
|
fontPreference: z
|
||||||
|
.enum(["brand", "platform", "inter", "serif"])
|
||||||
|
.optional(),
|
||||||
|
bodyFontPreference: z
|
||||||
|
.enum(["brand", "platform", "inter", "serif"])
|
||||||
|
.optional(),
|
||||||
|
headingFontPreference: z
|
||||||
|
.enum(["brand", "platform", "inter", "serif"])
|
||||||
|
.optional(),
|
||||||
|
radiusPreference: z.enum(["none", "sm", "md", "lg", "xl"]).optional(),
|
||||||
|
sidebarStyle: z.enum(["floating", "docked"]).optional(),
|
||||||
|
brandName: z.string().min(1).max(100).optional(),
|
||||||
|
brandTagline: z.string().min(1).max(255).optional(),
|
||||||
|
brandLogoText: z.string().min(1).max(100).optional(),
|
||||||
|
brandIcon: z.string().min(1).max(20).optional(),
|
||||||
|
pdfTemplate: z.enum(["classic", "minimal"]).optional(),
|
||||||
|
pdfAccentColor: z.string().min(4).max(50).optional(),
|
||||||
|
pdfFooterText: z.string().min(1).max(120).optional(),
|
||||||
|
pdfShowLogo: z.boolean().optional(),
|
||||||
|
pdfShowPageNumbers: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await requireAdmin(ctx);
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(users)
|
.insert(platformSettings)
|
||||||
.set({
|
.values({
|
||||||
...(input.colorTheme && { colorTheme: input.colorTheme }),
|
id: "global",
|
||||||
...(input.customColor !== undefined && { customColor: input.customColor }),
|
brandName: input.brandName ?? "beenvoice",
|
||||||
...(input.theme && { theme: input.theme }),
|
brandTagline:
|
||||||
|
input.brandTagline ??
|
||||||
|
"Simple and efficient invoicing for freelancers and small businesses",
|
||||||
|
brandLogoText: input.brandLogoText ?? "beenvoice",
|
||||||
|
brandIcon: input.brandIcon ?? "$",
|
||||||
|
colorTheme: input.colorTheme ?? "slate",
|
||||||
|
customColor: input.customColor,
|
||||||
|
theme: input.theme ?? "system",
|
||||||
|
interfaceTheme: input.interfaceTheme ?? defaultInterfaceTheme,
|
||||||
|
bodyFontPreference:
|
||||||
|
input.bodyFontPreference ?? defaultBodyFontPreference,
|
||||||
|
headingFontPreference:
|
||||||
|
input.headingFontPreference ?? defaultHeadingFontPreference,
|
||||||
|
radiusPreference: input.radiusPreference ?? defaultRadiusPreference,
|
||||||
|
sidebarStyle: input.sidebarStyle ?? defaultSidebarStyle,
|
||||||
|
pdfTemplate: input.pdfTemplate ?? "classic",
|
||||||
|
pdfAccentColor: input.pdfAccentColor ?? "#111827",
|
||||||
|
pdfFooterText: input.pdfFooterText ?? "Professional Invoicing",
|
||||||
|
pdfShowLogo: input.pdfShowLogo ?? true,
|
||||||
|
pdfShowPageNumbers: input.pdfShowPageNumbers ?? true,
|
||||||
})
|
})
|
||||||
.where(eq(users.id, ctx.session.user.id));
|
.onConflictDoUpdate({
|
||||||
|
target: platformSettings.id,
|
||||||
|
set: {
|
||||||
|
...(input.brandName && { brandName: input.brandName }),
|
||||||
|
...(input.brandTagline && { brandTagline: input.brandTagline }),
|
||||||
|
...(input.brandLogoText && {
|
||||||
|
brandLogoText: input.brandLogoText,
|
||||||
|
}),
|
||||||
|
...(input.brandIcon && { brandIcon: input.brandIcon }),
|
||||||
|
...(input.colorTheme && { colorTheme: input.colorTheme }),
|
||||||
|
...(input.customColor !== undefined && {
|
||||||
|
customColor: input.customColor,
|
||||||
|
}),
|
||||||
|
...(input.theme && { theme: input.theme }),
|
||||||
|
...(input.interfaceTheme && {
|
||||||
|
interfaceTheme: input.interfaceTheme,
|
||||||
|
}),
|
||||||
|
...(input.bodyFontPreference && {
|
||||||
|
bodyFontPreference: input.bodyFontPreference,
|
||||||
|
}),
|
||||||
|
...(input.headingFontPreference && {
|
||||||
|
headingFontPreference: input.headingFontPreference,
|
||||||
|
}),
|
||||||
|
...(input.radiusPreference && {
|
||||||
|
radiusPreference: input.radiusPreference,
|
||||||
|
}),
|
||||||
|
...(input.sidebarStyle && { sidebarStyle: input.sidebarStyle }),
|
||||||
|
...(input.pdfTemplate && { pdfTemplate: input.pdfTemplate }),
|
||||||
|
...(input.pdfAccentColor && {
|
||||||
|
pdfAccentColor: input.pdfAccentColor,
|
||||||
|
}),
|
||||||
|
...(input.pdfFooterText && { pdfFooterText: input.pdfFooterText }),
|
||||||
|
...(input.pdfShowLogo !== undefined && {
|
||||||
|
pdfShowLogo: input.pdfShowLogo,
|
||||||
|
}),
|
||||||
|
...(input.pdfShowPageNumbers !== undefined && {
|
||||||
|
pdfShowPageNumbers: input.pdfShowPageNumbers,
|
||||||
|
}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
@@ -390,6 +563,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
totalAmount: invoice.totalAmount,
|
totalAmount: invoice.totalAmount,
|
||||||
taxRate: invoice.taxRate,
|
taxRate: invoice.taxRate,
|
||||||
notes: invoice.notes ?? undefined,
|
notes: invoice.notes ?? undefined,
|
||||||
|
emailMessage: invoice.emailMessage ?? undefined,
|
||||||
items: invoice.items,
|
items: invoice.items,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -469,6 +643,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
totalAmount: invoiceData.totalAmount,
|
totalAmount: invoiceData.totalAmount,
|
||||||
taxRate: invoiceData.taxRate,
|
taxRate: invoiceData.taxRate,
|
||||||
notes: invoiceData.notes,
|
notes: invoiceData.notes,
|
||||||
|
emailMessage: invoiceData.emailMessage,
|
||||||
createdById: userId,
|
createdById: userId,
|
||||||
})
|
})
|
||||||
.returning({ id: invoices.id });
|
.returning({ id: invoices.id });
|
||||||
|
|||||||
+229
-4
@@ -6,6 +6,10 @@
|
|||||||
* This applies any pending migrations from the drizzle/ directory to the
|
* This applies any pending migrations from the drizzle/ directory to the
|
||||||
* database specified by DATABASE_URL. It is safe to run multiple times —
|
* database specified by DATABASE_URL. It is safe to run multiple times —
|
||||||
* Drizzle tracks applied migrations in the __drizzle_migrations table.
|
* Drizzle tracks applied migrations in the __drizzle_migrations table.
|
||||||
|
*
|
||||||
|
* If the database was previously set up via `db:push` (no migration history),
|
||||||
|
* this script will baseline it: seed the migration history without re-running
|
||||||
|
* the SQL, so only future migrations are applied.
|
||||||
*/
|
*/
|
||||||
import * as dotenv from "dotenv";
|
import * as dotenv from "dotenv";
|
||||||
|
|
||||||
@@ -17,7 +21,8 @@ import { Pool } from "pg";
|
|||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import fs from "fs";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
@@ -25,20 +30,240 @@ if (!databaseUrl) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const migrationsFolder = path.resolve(process.cwd(), "drizzle");
|
||||||
const migrationsFolder = path.resolve(__dirname, "../../../drizzle");
|
|
||||||
|
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
connectionString: databaseUrl,
|
connectionString: databaseUrl,
|
||||||
ssl: process.env.DB_DISABLE_SSL === "true" ? false : { rejectUnauthorized: false },
|
ssl:
|
||||||
|
process.env.DB_DISABLE_SSL === "true"
|
||||||
|
? false
|
||||||
|
: { rejectUnauthorized: false },
|
||||||
max: 1,
|
max: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const db = drizzle(pool);
|
const db = drizzle(pool);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify and repair the migration tracking table:
|
||||||
|
* 1. If no tracking table exists and DB has tables → baseline from db:push
|
||||||
|
* 2. If tracking table exists → scan for any entries that are recorded as
|
||||||
|
* applied but whose schema changes don't actually exist, and remove them
|
||||||
|
* so migrate() will re-run those migrations.
|
||||||
|
*/
|
||||||
|
async function baselineIfNeeded(client: Pool) {
|
||||||
|
const hasMigrationsTable = await tableExists(
|
||||||
|
client,
|
||||||
|
"drizzle",
|
||||||
|
"__drizzle_migrations",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Always ensure the drizzle schema + table exist
|
||||||
|
await client.query(`CREATE SCHEMA IF NOT EXISTS drizzle`);
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hash text NOT NULL,
|
||||||
|
created_at bigint
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const { rows: entryRows } = await client.query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations`,
|
||||||
|
);
|
||||||
|
const hasEntries = parseInt(entryRows[0]?.count ?? "0") > 0;
|
||||||
|
|
||||||
|
if (!hasMigrationsTable || !hasEntries) {
|
||||||
|
// No history at all — check if DB was previously set up via db:push
|
||||||
|
const dbAlreadyExists = await tableExists(
|
||||||
|
client,
|
||||||
|
"public",
|
||||||
|
"beenvoice_account",
|
||||||
|
);
|
||||||
|
if (!dbAlreadyExists) {
|
||||||
|
return; // Fresh DB — let migrate() run everything normally
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[migrate] Existing database detected without migration history — baselining...",
|
||||||
|
);
|
||||||
|
await seedMigrationHistory(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration history exists — validate that each recorded migration is
|
||||||
|
// actually reflected in the schema. Remove any bogus entries.
|
||||||
|
await removeBogusEntries(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedMigrationHistory(client: Pool) {
|
||||||
|
const journal = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8"),
|
||||||
|
) as { entries: { idx: number; tag: string; when: number }[] };
|
||||||
|
|
||||||
|
for (const entry of journal.entries) {
|
||||||
|
const applied = await isMigrationApplied(client, entry.tag);
|
||||||
|
if (!applied) {
|
||||||
|
console.log(`[migrate] Not yet in schema, will run: ${entry.tag}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sql = fs.readFileSync(
|
||||||
|
path.join(migrationsFolder, `${entry.tag}.sql`),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const hash = crypto.createHash("sha256").update(sql).digest("hex");
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)`,
|
||||||
|
[hash, entry.when],
|
||||||
|
);
|
||||||
|
console.log(`[migrate] Baselined: ${entry.tag}`);
|
||||||
|
}
|
||||||
|
console.log("[migrate] Baseline complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeBogusEntries(client: Pool) {
|
||||||
|
// Get all recorded hashes
|
||||||
|
const { rows } = await client.query<{ id: number; hash: string }>(
|
||||||
|
`SELECT id, hash FROM drizzle.__drizzle_migrations ORDER BY id`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const journal = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8"),
|
||||||
|
) as { entries: { idx: number; tag: string; when: number }[] };
|
||||||
|
|
||||||
|
for (const entry of journal.entries) {
|
||||||
|
const sql = fs.readFileSync(
|
||||||
|
path.join(migrationsFolder, `${entry.tag}.sql`),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const expectedHash = crypto.createHash("sha256").update(sql).digest("hex");
|
||||||
|
const recorded = rows.find((r) => r.hash === expectedHash);
|
||||||
|
if (!recorded) continue; // Not recorded yet — migrate() will run it
|
||||||
|
|
||||||
|
// It's recorded — verify it's actually applied in the schema
|
||||||
|
const applied = await isMigrationApplied(client, entry.tag);
|
||||||
|
if (!applied) {
|
||||||
|
console.log(
|
||||||
|
`[migrate] Removing bogus migration record for: ${entry.tag}`,
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM drizzle.__drizzle_migrations WHERE id = $1`,
|
||||||
|
[recorded.id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tableExists(
|
||||||
|
client: Pool,
|
||||||
|
schema: string,
|
||||||
|
table: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { rows } = await client.query<{ count: string }>(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.tables
|
||||||
|
WHERE table_schema = $1 AND table_name = $2
|
||||||
|
`,
|
||||||
|
[schema, table],
|
||||||
|
);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a specific migration's schema changes already exist in the DB.
|
||||||
|
*/
|
||||||
|
async function isMigrationApplied(client: Pool, tag: string): Promise<boolean> {
|
||||||
|
if (tag === "0000_glossy_magneto") {
|
||||||
|
return tableExists(client, "public", "beenvoice_account");
|
||||||
|
}
|
||||||
|
if (tag === "0001_supreme_the_enforcers") {
|
||||||
|
// 0001 adds currency to beenvoice_client
|
||||||
|
const { rows } = await client.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'beenvoice_client'
|
||||||
|
AND column_name = 'currency'
|
||||||
|
`);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
if (tag === "0002_tax_deductible") {
|
||||||
|
// 0002 adds taxDeductible to beenvoice_expense
|
||||||
|
const { rows } = await client.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'beenvoice_expense'
|
||||||
|
AND column_name = 'taxDeductible'
|
||||||
|
`);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
if (tag === "0003_appearance_preferences") {
|
||||||
|
// 0003 adds appearance preferences to beenvoice_user
|
||||||
|
const { rows } = await client.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'beenvoice_user'
|
||||||
|
AND column_name = 'interfaceTheme'
|
||||||
|
`);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
if (tag === "0004_platform_appearance_controls") {
|
||||||
|
// 0004 adds platform-level appearance controls to beenvoice_user
|
||||||
|
const { rows } = await client.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'beenvoice_user'
|
||||||
|
AND column_name = 'sidebarStyle'
|
||||||
|
`);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
if (tag === "0005_platform_settings_and_roles") {
|
||||||
|
const hasRole = await columnExists(
|
||||||
|
client,
|
||||||
|
"public",
|
||||||
|
"beenvoice_user",
|
||||||
|
"role",
|
||||||
|
);
|
||||||
|
const hasPlatformSettings = await tableExists(
|
||||||
|
client,
|
||||||
|
"public",
|
||||||
|
"beenvoice_platform_setting",
|
||||||
|
);
|
||||||
|
return hasRole && hasPlatformSettings;
|
||||||
|
}
|
||||||
|
if (tag === "0006_pdf_generation_settings") {
|
||||||
|
return columnExists(
|
||||||
|
client,
|
||||||
|
"public",
|
||||||
|
"beenvoice_platform_setting",
|
||||||
|
"pdfTemplate",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tag === "0007_invoice_email_message") {
|
||||||
|
return columnExists(client, "public", "beenvoice_invoice", "emailMessage");
|
||||||
|
}
|
||||||
|
// Unknown migration — assume not applied so it runs
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function columnExists(
|
||||||
|
client: Pool,
|
||||||
|
schema: string,
|
||||||
|
table: string,
|
||||||
|
column: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { rows } = await client.query<{ count: string }>(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.columns
|
||||||
|
WHERE table_schema = $1 AND table_name = $2 AND column_name = $3
|
||||||
|
`,
|
||||||
|
[schema, table, column],
|
||||||
|
);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[migrate] Running migrations from", migrationsFolder);
|
console.log("[migrate] Running migrations from", migrationsFolder);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await baselineIfNeeded(pool);
|
||||||
await migrate(db, { migrationsFolder });
|
await migrate(db, { migrationsFolder });
|
||||||
console.log("[migrate] All migrations applied successfully");
|
console.log("[migrate] All migrations applied successfully");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+95
-15
@@ -1,7 +1,6 @@
|
|||||||
import { relations, sql } from "drizzle-orm";
|
import { relations, sql } from "drizzle-orm";
|
||||||
import { index, pgTableCreator } from "drizzle-orm/pg-core";
|
import { index, pgTableCreator } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||||
* database instance for multiple projects.
|
* database instance for multiple projects.
|
||||||
@@ -22,7 +21,11 @@ export const users = createTable("user", (d) => ({
|
|||||||
emailVerified: d.boolean().default(false).notNull(),
|
emailVerified: d.boolean().default(false).notNull(),
|
||||||
image: d.varchar({ length: 255 }),
|
image: d.varchar({ length: 255 }),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp().notNull().defaultNow(),
|
||||||
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
|
updatedAt: d
|
||||||
|
.timestamp()
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
password: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
password: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
||||||
resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
||||||
resetTokenExpiry: d.timestamp(),
|
resetTokenExpiry: d.timestamp(),
|
||||||
@@ -32,6 +35,48 @@ export const users = createTable("user", (d) => ({
|
|||||||
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
|
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
|
||||||
customColor: d.varchar({ length: 50 }),
|
customColor: d.varchar({ length: 50 }),
|
||||||
theme: d.varchar({ length: 20 }).default("system").notNull(),
|
theme: d.varchar({ length: 20 }).default("system").notNull(),
|
||||||
|
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
|
||||||
|
fontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
||||||
|
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
||||||
|
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
||||||
|
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
|
||||||
|
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
|
||||||
|
role: d.varchar({ length: 20 }).default("user").notNull(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const platformSettings = createTable("platform_setting", (d) => ({
|
||||||
|
id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"),
|
||||||
|
brandName: d.varchar({ length: 100 }).default("beenvoice").notNull(),
|
||||||
|
brandTagline: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.default(
|
||||||
|
"Simple and efficient invoicing for freelancers and small businesses",
|
||||||
|
)
|
||||||
|
.notNull(),
|
||||||
|
brandLogoText: d.varchar({ length: 100 }).default("beenvoice").notNull(),
|
||||||
|
brandIcon: d.varchar({ length: 20 }).default("$").notNull(),
|
||||||
|
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
|
||||||
|
customColor: d.varchar({ length: 50 }),
|
||||||
|
theme: d.varchar({ length: 20 }).default("system").notNull(),
|
||||||
|
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
|
||||||
|
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
||||||
|
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
|
||||||
|
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
|
||||||
|
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
|
||||||
|
pdfTemplate: d.varchar({ length: 20 }).default("classic").notNull(),
|
||||||
|
pdfAccentColor: d.varchar({ length: 50 }).default("#111827").notNull(),
|
||||||
|
pdfFooterText: d
|
||||||
|
.varchar({ length: 120 })
|
||||||
|
.default("Professional Invoicing")
|
||||||
|
.notNull(),
|
||||||
|
pdfShowLogo: d.boolean().default(true).notNull(),
|
||||||
|
pdfShowPageNumbers: d.boolean().default(true).notNull(),
|
||||||
|
createdAt: d.timestamp().notNull().defaultNow(),
|
||||||
|
updatedAt: d
|
||||||
|
.timestamp()
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
@@ -47,7 +92,11 @@ export const usersRelations = relations(users, ({ many }) => ({
|
|||||||
export const accounts = createTable(
|
export const accounts = createTable(
|
||||||
"account",
|
"account",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
id: d
|
||||||
|
.text()
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
||||||
userId: d
|
userId: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -62,11 +111,13 @@ export const accounts = createTable(
|
|||||||
idToken: d.text(),
|
idToken: d.text(),
|
||||||
password: d.text(), // Matched DB: text
|
password: d.text(), // Matched DB: text
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp().notNull().defaultNow(),
|
||||||
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
|
updatedAt: d
|
||||||
|
.timestamp()
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [index("account_userId_idx").on(t.userId)],
|
||||||
index("account_userId_idx").on(t.userId),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const accountsRelations = relations(accounts, ({ one }) => ({
|
export const accountsRelations = relations(accounts, ({ one }) => ({
|
||||||
@@ -76,7 +127,11 @@ export const accountsRelations = relations(accounts, ({ one }) => ({
|
|||||||
export const sessions = createTable(
|
export const sessions = createTable(
|
||||||
"session",
|
"session",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
id: d
|
||||||
|
.text()
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
||||||
userId: d
|
userId: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -86,7 +141,11 @@ export const sessions = createTable(
|
|||||||
ipAddress: d.text(), // Matched DB: text
|
ipAddress: d.text(), // Matched DB: text
|
||||||
userAgent: d.text(), // Matched DB: text
|
userAgent: d.text(), // Matched DB: text
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp().notNull().defaultNow(),
|
||||||
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
|
updatedAt: d
|
||||||
|
.timestamp()
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [index("session_userId_idx").on(t.userId)],
|
(t) => [index("session_userId_idx").on(t.userId)],
|
||||||
);
|
);
|
||||||
@@ -98,12 +157,20 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({
|
|||||||
export const verificationTokens = createTable(
|
export const verificationTokens = createTable(
|
||||||
"verification_token",
|
"verification_token",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
id: d
|
||||||
|
.text()
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
||||||
identifier: d.varchar({ length: 255 }).notNull(),
|
identifier: d.varchar({ length: 255 }).notNull(),
|
||||||
value: d.varchar({ length: 255 }).notNull(),
|
value: d.varchar({ length: 255 }).notNull(),
|
||||||
expiresAt: d.timestamp().notNull(),
|
expiresAt: d.timestamp().notNull(),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp().notNull().defaultNow(),
|
||||||
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
|
updatedAt: d
|
||||||
|
.timestamp()
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [index("verification_token_identifier_idx").on(t.identifier)],
|
(t) => [index("verification_token_identifier_idx").on(t.identifier)],
|
||||||
);
|
);
|
||||||
@@ -111,14 +178,25 @@ export const verificationTokens = createTable(
|
|||||||
export const ssoProviders = createTable(
|
export const ssoProviders = createTable(
|
||||||
"sso_provider",
|
"sso_provider",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
id: d.varchar({ length: 255 }).notNull().primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
providerId: d.varchar({ length: 255 }).notNull().unique(),
|
providerId: d.varchar({ length: 255 }).notNull().unique(),
|
||||||
userId: d.varchar({ length: 255 }).notNull().references(() => users.id),
|
userId: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields
|
redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields
|
||||||
oidcConfig: d.text(),
|
oidcConfig: d.text(),
|
||||||
samlConfig: d.text(),
|
samlConfig: d.text(),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp().notNull().defaultNow(),
|
||||||
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
|
updatedAt: d
|
||||||
|
.timestamp()
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [index("sso_provider_user_id_idx").on(t.userId)],
|
(t) => [index("sso_provider_user_id_idx").on(t.userId)],
|
||||||
);
|
);
|
||||||
@@ -230,6 +308,7 @@ export const invoices = createTable(
|
|||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => crypto.randomUUID()),
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
invoiceNumber: d.varchar({ length: 100 }).notNull(),
|
invoiceNumber: d.varchar({ length: 100 }).notNull(),
|
||||||
|
invoicePrefix: d.varchar({ length: 20 }).default("#"),
|
||||||
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
|
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
|
||||||
clientId: d
|
clientId: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
@@ -241,6 +320,7 @@ export const invoices = createTable(
|
|||||||
totalAmount: d.real().notNull().default(0),
|
totalAmount: d.real().notNull().default(0),
|
||||||
taxRate: d.real().notNull().default(0.0),
|
taxRate: d.real().notNull().default(0.0),
|
||||||
notes: d.varchar({ length: 1000 }),
|
notes: d.varchar({ length: 1000 }),
|
||||||
|
emailMessage: d.varchar({ length: 2000 }),
|
||||||
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||||
createdById: d
|
createdById: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
@@ -334,6 +414,7 @@ export const expenses = createTable(
|
|||||||
category: d.varchar({ length: 100 }),
|
category: d.varchar({ length: 100 }),
|
||||||
billable: d.boolean().default(false).notNull(),
|
billable: d.boolean().default(false).notNull(),
|
||||||
reimbursable: d.boolean().default(false).notNull(),
|
reimbursable: d.boolean().default(false).notNull(),
|
||||||
|
taxDeductible: d.boolean().default(false).notNull(),
|
||||||
notes: d.varchar({ length: 500 }),
|
notes: d.varchar({ length: 500 }),
|
||||||
createdById: d
|
createdById: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
@@ -410,4 +491,3 @@ export const invoiceTemplatesRelations = relations(
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+310
-4
@@ -34,8 +34,155 @@
|
|||||||
/* 16px Global Radius */
|
/* 16px Global Radius */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="shadcn"] {
|
||||||
|
--primary: 240 5.9% 10%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 240 4.8% 95.9%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="beenvoice"] {
|
||||||
|
--secondary: 240 4.8% 90%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
--radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="minimal"] {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--primary: 240 5.9% 10%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 240 4.8% 96.5%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
--muted: 240 4.8% 97%;
|
||||||
|
--accent: 240 4.8% 96%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="editorial"] {
|
||||||
|
--background: 36 33% 98%;
|
||||||
|
--card: 36 33% 99%;
|
||||||
|
--popover: 36 33% 99%;
|
||||||
|
--primary: 346.8 77.2% 49.8%;
|
||||||
|
--primary-foreground: 355.7 100% 97.3%;
|
||||||
|
--secondary: 30 18% 91%;
|
||||||
|
--secondary-foreground: 24 10% 10%;
|
||||||
|
--muted: 30 20% 94%;
|
||||||
|
--accent: 346.8 77.2% 49.8%;
|
||||||
|
--accent-foreground: 355.7 100% 97.3%;
|
||||||
|
--border: 30 15% 86%;
|
||||||
|
--input: 30 15% 86%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-body-font="brand"],
|
||||||
|
:root[data-body-font="inter"] {
|
||||||
|
--app-font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-body-font="platform"] {
|
||||||
|
--app-font-sans:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-body-font="serif"] {
|
||||||
|
--app-font-sans:
|
||||||
|
ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-heading-font="brand"],
|
||||||
|
:root[data-heading-font="serif"] {
|
||||||
|
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-heading-font="platform"] {
|
||||||
|
--app-font-heading:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-heading-font="inter"] {
|
||||||
|
--app-font-heading: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-font="brand"]:not([data-body-font]) {
|
||||||
|
--app-font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-font="platform"]:not([data-body-font]) {
|
||||||
|
--app-font-sans:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
--app-font-heading:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-font="inter"]:not([data-body-font]) {
|
||||||
|
--app-font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--app-font-heading: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-font="serif"]:not([data-body-font]) {
|
||||||
|
--app-font-sans:
|
||||||
|
ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||||
|
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-radius="none"] {
|
||||||
|
--radius: 0rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-radius="sm"] {
|
||||||
|
--radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-radius="md"] {
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-radius="lg"] {
|
||||||
|
--radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-radius="xl"] {
|
||||||
|
--radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-mode="dark"],
|
||||||
|
:root.dark {
|
||||||
|
--background: 240 10% 3.9%;
|
||||||
|
/* #09090B */
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
/* #FAFAFA */
|
||||||
|
--card: 240 10% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 240 10% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 240 5.9% 10%;
|
||||||
|
--secondary: 240 3.7% 20%;
|
||||||
|
/* #27272A */
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 240 3.7% 15.9%;
|
||||||
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
--accent: 240 3.7% 15.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 3.7% 15.9%;
|
||||||
|
/* #27272A */
|
||||||
|
--input: 240 3.7% 15.9%;
|
||||||
|
--ring: 240 4.9% 83.9%;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root:not([data-color-mode="light"]) {
|
||||||
--background: 240 10% 3.9%;
|
--background: 240 10% 3.9%;
|
||||||
/* #09090B */
|
/* #09090B */
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
@@ -61,6 +208,65 @@
|
|||||||
--ring: 240 4.9% 83.9%;
|
--ring: 240 4.9% 83.9%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme="slate"] {
|
||||||
|
--primary: 240 5.9% 10%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme="blue"] {
|
||||||
|
--primary: 221.2 83.2% 53.3%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--accent: 217.2 91.2% 59.8%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme="green"] {
|
||||||
|
--primary: 142.1 76.2% 36.3%;
|
||||||
|
--primary-foreground: 355.7 100% 97.3%;
|
||||||
|
--accent: 142.1 70.6% 45.3%;
|
||||||
|
--accent-foreground: 355.7 100% 97.3%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme="rose"] {
|
||||||
|
--primary: 346.8 77.2% 49.8%;
|
||||||
|
--primary-foreground: 355.7 100% 97.3%;
|
||||||
|
--accent: 346.8 77.2% 49.8%;
|
||||||
|
--accent-foreground: 355.7 100% 97.3%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme="orange"] {
|
||||||
|
--primary: 24.6 95% 53.1%;
|
||||||
|
--primary-foreground: 60 9.1% 97.8%;
|
||||||
|
--accent: 20.5 90.2% 48.2%;
|
||||||
|
--accent-foreground: 60 9.1% 97.8%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-theme="custom"] {
|
||||||
|
--primary: var(--custom-primary, 142.1 76.2% 36.3%);
|
||||||
|
--primary-foreground: 355.7 100% 97.3%;
|
||||||
|
--accent: var(--custom-primary, 142.1 76.2% 36.3%);
|
||||||
|
--accent-foreground: 355.7 100% 97.3%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-color-mode="dark"][data-color-theme="slate"],
|
||||||
|
:root.dark[data-color-theme="slate"] {
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 240 5.9% 10%;
|
||||||
|
--accent: 240 3.7% 15.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-color-mode="light"])[data-color-theme="slate"] {
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 240 5.9% 10%;
|
||||||
|
--accent: 240 3.7% 15.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -84,9 +290,9 @@
|
|||||||
--color-input: hsl(var(--input));
|
--color-input: hsl(var(--input));
|
||||||
--color-ring: hsl(var(--ring));
|
--color-ring: hsl(var(--ring));
|
||||||
|
|
||||||
--font-sans: var(--font-sans), sans-serif;
|
--font-sans: var(--app-font-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-heading: var(--font-heading), serif;
|
--font-heading: var(--app-font-heading), ui-serif, Georgia, serif;
|
||||||
--font-mono: var(--font-geist-mono), monospace;
|
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
@@ -114,6 +320,87 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
:root[data-interface-theme="shadcn"] .brand-background,
|
||||||
|
:root[data-interface-theme="minimal"] .brand-background {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="minimal"] [data-slot="card"] {
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
border-top-color: hsl(var(--border));
|
||||||
|
box-shadow: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="minimal"] [data-slot="card"] + [data-slot="card"],
|
||||||
|
:root[data-interface-theme="minimal"] .form-section + .form-section {
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="minimal"] [data-slot="card-header"],
|
||||||
|
:root[data-interface-theme="minimal"] [data-slot="card-content"],
|
||||||
|
:root[data-interface-theme="minimal"] [data-slot="card-footer"] {
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="minimal"] [data-slot="card-header"] {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="minimal"] [data-slot="card-content"] {
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="minimal"] .page-enter,
|
||||||
|
:root[data-interface-theme="minimal"] [class*="space-y-8"],
|
||||||
|
:root[data-interface-theme="minimal"] [class*="space-y-6"] {
|
||||||
|
row-gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="minimal"]
|
||||||
|
[class*="space-y-8"]
|
||||||
|
> :not([hidden])
|
||||||
|
~ :not([hidden]),
|
||||||
|
:root[data-interface-theme="minimal"]
|
||||||
|
[class*="space-y-6"]
|
||||||
|
> :not([hidden])
|
||||||
|
~ :not([hidden]) {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="minimal"] [class*="gap-6"] {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="minimal"] .platform-header-surface {
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="minimal"] .platform-header-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="minimal"] .platform-header-gradient {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="minimal"] .bg-dashboard {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-interface-theme="editorial"] .brand-background {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
.animate-blob {
|
.animate-blob {
|
||||||
animation: blob 7s infinite;
|
animation: blob 7s infinite;
|
||||||
}
|
}
|
||||||
@@ -135,6 +422,25 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px -4px hsl(var(--foreground) / 0.1);
|
box-shadow: 0 4px 12px -4px hsl(var(--foreground) / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-radius] .rounded-sm {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-radius] .rounded,
|
||||||
|
:root[data-radius] .rounded-md {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-radius] .rounded-lg {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-radius] .rounded-xl,
|
||||||
|
:root[data-radius] .rounded-2xl,
|
||||||
|
:root[data-radius] .rounded-3xl {
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blob {
|
@keyframes blob {
|
||||||
|
|||||||
+4
-4
@@ -7,7 +7,6 @@ import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import SuperJSON from "superjson";
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
import { type AppRouter } from "~/server/api/root";
|
|
||||||
import { createQueryClient } from "./query-client";
|
import { createQueryClient } from "./query-client";
|
||||||
|
|
||||||
let clientQueryClientSingleton: QueryClient | undefined = undefined;
|
let clientQueryClientSingleton: QueryClient | undefined = undefined;
|
||||||
@@ -22,21 +21,22 @@ const getQueryClient = () => {
|
|||||||
return clientQueryClientSingleton;
|
return clientQueryClientSingleton;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const api = createTRPCReact<AppRouter>();
|
// Use inline import() type to avoid pulling server modules into the client bundle
|
||||||
|
export const api = createTRPCReact<import("~/server/api/root").AppRouter>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inference helper for inputs.
|
* Inference helper for inputs.
|
||||||
*
|
*
|
||||||
* @example type HelloInput = RouterInputs['example']['hello']
|
* @example type HelloInput = RouterInputs['example']['hello']
|
||||||
*/
|
*/
|
||||||
export type RouterInputs = inferRouterInputs<AppRouter>;
|
export type RouterInputs = inferRouterInputs<import("~/server/api/root").AppRouter>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inference helper for outputs.
|
* Inference helper for outputs.
|
||||||
*
|
*
|
||||||
* @example type HelloOutput = RouterOutputs['example']['hello']
|
* @example type HelloOutput = RouterOutputs['example']['hello']
|
||||||
*/
|
*/
|
||||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
export type RouterOutputs = inferRouterOutputs<import("~/server/api/root").AppRouter>;
|
||||||
|
|
||||||
export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface Invoice {
|
|||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
emailMessage: string | null;
|
||||||
createdById: string;
|
createdById: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "[start.sh] Starting beenvoice in production mode"
|
|
||||||
|
|
||||||
# Detect if running inside a Docker container
|
|
||||||
IS_DOCKER=false
|
|
||||||
if [ -f /\.dockerenv ]; then
|
|
||||||
IS_DOCKER=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$IS_DOCKER" = false ]; then
|
|
||||||
## Host mode: prepare env, then run containers
|
|
||||||
if [ ! -f ./.env ] && { [ -f ./.env.example ] || [ -f ./env.example ]; }; then
|
|
||||||
echo "[start.sh] No .env detected. Creating from env.example with generated secrets..."
|
|
||||||
GEN_AUTH_SECRET=$(openssl rand -hex 32 || cat /proc/sys/kernel/random/uuid)
|
|
||||||
GEN_DB_PASSWORD=$(openssl rand -hex 16 || cat /proc/sys/kernel/random/uuid)
|
|
||||||
tmp_env=$(mktemp)
|
|
||||||
ENV_TEMPLATE="./.env.example"
|
|
||||||
if [ -f ./env.example ]; then ENV_TEMPLATE="./env.example"; fi
|
|
||||||
sed \
|
|
||||||
-e "s/^AUTH_SECRET=__GENERATE__/AUTH_SECRET=${GEN_AUTH_SECRET}/" \
|
|
||||||
-e "s/^POSTGRES_PASSWORD=__GENERATE__/POSTGRES_PASSWORD=${GEN_DB_PASSWORD}/" \
|
|
||||||
"$ENV_TEMPLATE" > "$tmp_env"
|
|
||||||
mv "$tmp_env" ./.env
|
|
||||||
echo "[start.sh] Created .env. Please review and edit it as needed, then run this script again."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Auto-generate missing placeholders in existing .env
|
|
||||||
if [ -f ./.env ]; then
|
|
||||||
set -a; . ./.env; set +a
|
|
||||||
updated_env=false
|
|
||||||
if [ -z "${AUTH_SECRET:-}" ] || grep -qE '^AUTH_SECRET=($|__GENERATE__)' ./.env; then
|
|
||||||
new_auth_secret=$(openssl rand -hex 32 || cat /proc/sys/kernel/random/uuid)
|
|
||||||
sed -i.bak -e "s/^AUTH_SECRET=.*/AUTH_SECRET=${new_auth_secret}/" ./.env || echo "AUTH_SECRET=${new_auth_secret}" >> ./.env
|
|
||||||
updated_env=true
|
|
||||||
fi
|
|
||||||
if [ -z "${POSTGRES_PASSWORD:-}" ] || grep -qE '^POSTGRES_PASSWORD=($|__GENERATE__)' ./.env; then
|
|
||||||
new_db_pw=$(openssl rand -hex 16 || cat /proc/sys/kernel/random/uuid)
|
|
||||||
sed -i.bak -e "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=${new_db_pw}/" ./.env || echo "POSTGRES_PASSWORD=${new_db_pw}" >> ./.env
|
|
||||||
updated_env=true
|
|
||||||
fi
|
|
||||||
if [ "$updated_env" = true ]; then rm -f ./.env.bak || true; fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure docker is available
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
|
||||||
echo "[start.sh] ERROR: docker is not installed or not in PATH." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[start.sh] Bringing up containers with docker compose..."
|
|
||||||
docker compose up -d
|
|
||||||
echo "[start.sh] Containers started. View logs with: docker compose logs -f app"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Container mode: continue to runtime checks and start app
|
|
||||||
|
|
||||||
# If .env exists but secrets are missing or placeholders, auto-generate and update file
|
|
||||||
updated_env=false
|
|
||||||
if [ -f ./.env ]; then
|
|
||||||
if [ -z "${AUTH_SECRET:-}" ] || grep -qE '^AUTH_SECRET=($|__GENERATE__)' ./.env; then
|
|
||||||
new_auth_secret=$(openssl rand -hex 32 || cat /proc/sys/kernel/random/uuid)
|
|
||||||
sed -i.bak -e "s/^AUTH_SECRET=.*/AUTH_SECRET=${new_auth_secret}/" ./.env || echo "AUTH_SECRET=${new_auth_secret}" >> ./.env
|
|
||||||
AUTH_SECRET=${new_auth_secret}
|
|
||||||
updated_env=true
|
|
||||||
fi
|
|
||||||
if [ -z "${POSTGRES_PASSWORD:-}" ] || grep -qE '^POSTGRES_PASSWORD=($|__GENERATE__)' ./.env; then
|
|
||||||
new_db_pw=$(openssl rand -hex 16 || cat /proc/sys/kernel/random/uuid)
|
|
||||||
sed -i.bak -e "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=${new_db_pw}/" ./.env || echo "POSTGRES_PASSWORD=${new_db_pw}" >> ./.env
|
|
||||||
POSTGRES_PASSWORD=${new_db_pw}
|
|
||||||
updated_env=true
|
|
||||||
fi
|
|
||||||
# Compose DATABASE_URL if missing but POSTGRES_* present
|
|
||||||
if [ -z "${DATABASE_URL:-}" ] && [ -n "${POSTGRES_USER:-}" ] && [ -n "${POSTGRES_PASSWORD:-}" ] && [ -n "${POSTGRES_DB:-}" ]; then
|
|
||||||
DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}"
|
|
||||||
echo "DATABASE_URL=${DATABASE_URL}" >> ./.env
|
|
||||||
updated_env=true
|
|
||||||
fi
|
|
||||||
# Reload env if we updated it
|
|
||||||
if [ "$updated_env" = true ]; then
|
|
||||||
set -a
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
rm -f ./.env.bak || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure required env vars are present (fail fast for critical ones)
|
|
||||||
if [ -z "${DATABASE_URL:-}" ]; then
|
|
||||||
echo "[start.sh] ERROR: DATABASE_URL must be set (in .env or environment)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "${AUTH_SECRET:-}" ]; then
|
|
||||||
echo "[start.sh] ERROR: AUTH_SECRET must be set (in .env or environment)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "${RESEND_API_KEY:-}" ]; then
|
|
||||||
echo "[start.sh] ERROR: RESEND_API_KEY must be set (in .env or environment)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Optional: allow skipping migrations with SKIP_DB_MIGRATION=true
|
|
||||||
SKIP_DB_MIGRATION=${SKIP_DB_MIGRATION:-false}
|
|
||||||
|
|
||||||
if [ "$SKIP_DB_MIGRATION" != "true" ]; then
|
|
||||||
echo "[start.sh] Applying database migrations"
|
|
||||||
SKIP_ENV_VALIDATION=1 bun src/server/db/migrate.ts
|
|
||||||
else
|
|
||||||
echo "[start.sh] Skipping DB migration due to SKIP_DB_MIGRATION=${SKIP_DB_MIGRATION}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
PORT=${PORT:-3000}
|
|
||||||
HOSTNAME_ENV=${HOSTNAME:-0.0.0.0}
|
|
||||||
|
|
||||||
echo "[start.sh] Starting Next.js server on ${HOSTNAME_ENV}:${PORT}"
|
|
||||||
exec bun run start -p "${PORT}" -H "${HOSTNAME_ENV}"
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user