Compare commits
10 Commits
5c28b33e9f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 480c50981d | |||
| 0b7ffac4e7 | |||
| 9b72afdf69 | |||
| 9e7177a869 | |||
| 1928084acb | |||
| 4cd8ad3c4c | |||
| 5019a7597d | |||
| 40020b78f8 | |||
| 69da2bf71d | |||
| b7380f4348 |
@@ -1,5 +1,7 @@
|
|||||||
# beenvoice - AI Assistant Rules
|
# beenvoice - AI Assistant Rules
|
||||||
|
|
||||||
|
> **Canonical architecture reference:** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (stack, routers, schema, auth). This file may lag behind; prefer ARCHITECTURE.md for facts.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
beenvoice is a professional invoicing application built with the T3 stack (Next.js 15, tRPC, Drizzle/LibSQL, NextAuth.js) and shadcn/ui components. This is a business-critical application where reliability, security, and professional user experience are paramount.
|
beenvoice is a professional invoicing application built with the T3 stack (Next.js 15, tRPC, Drizzle/LibSQL, NextAuth.js) and shadcn/ui components. This is a business-critical application where reliability, security, and professional user experience are paramount.
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||

|

|
||||||
|
|
||||||
# beenvoice - Invoicing Made Simple
|
# beenvoice — Invoicing Made Simple
|
||||||
|
|
||||||
A modern, professional invoicing application built for freelancers and small businesses. beenvoice provides a clean, efficient way to manage clients and create professional invoices with ease.
|
Modern invoicing for freelancers and small businesses: clients, businesses, invoices, time tracking, expenses, recurring billing, PDF/email delivery, and optional SSO.
|
||||||
|
|
||||||

|
**Architecture (dense):** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
|
||||||
|
**Mobile companion:** [../beenvoice-app/README.md](../beenvoice-app/README.md)
|
||||||
|
|
||||||
## ✨ Features
|
## Stack at a glance
|
||||||
|
|
||||||
- **🔐 Secure Authentication** - Email/password registration and sign-in with better-auth, plus SSO via Authentik OIDC
|
| Layer | Tech |
|
||||||
|
|-------|------|
|
||||||
|
| App | Next.js 16 App Router, React 19 |
|
||||||
|
| API | tRPC 11 + SuperJSON |
|
||||||
|
| DB | PostgreSQL, Drizzle ORM |
|
||||||
|
| Auth | better-auth (email/password, Authentik OIDC, Expo mobile) |
|
||||||
|
| UI | shadcn/ui, Tailwind v4 |
|
||||||
|
| Email / PDF | Resend, @react-pdf/renderer |
|
||||||
|
| Package manager | Bun |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **🔐 Authentication** — better-auth: email/password, password reset, optional Authentik OIDC, Expo mobile sessions
|
||||||
|
- **⏱ Time clock** — running timer, one per user; clock-out can append invoice line items
|
||||||
|
- **🤖 MCP API** — `/api/mcp` for automation via API keys (`bv_…`)
|
||||||
- **👥 Client Management** - Create, edit, and manage client information
|
- **👥 Client Management** - Create, edit, and manage client information
|
||||||
- **🏢 Business Profiles** - Manage your business details, logo, and email settings
|
- **🏢 Business Profiles** - Manage your business details, logo, and email settings
|
||||||
- **📄 Professional Invoices** - Generate detailed invoices with line items
|
- **📄 Professional Invoices** - Generate detailed invoices with line items
|
||||||
@@ -103,35 +118,19 @@ A modern, professional invoicing application built for freelancers and small bus
|
|||||||
7. **Open your browser**
|
7. **Open your browser**
|
||||||
Navigate to [http://localhost:3000](http://localhost:3000)
|
Navigate to [http://localhost:3000](http://localhost:3000)
|
||||||
|
|
||||||
## 🏗️ Project Structure
|
## 🏗️ Project structure
|
||||||
|
|
||||||
|
See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for routers, schema, auth, and MCP.
|
||||||
|
|
||||||
```
|
```
|
||||||
beenvoice/
|
beenvoice/
|
||||||
├── src/
|
├── src/app/ # Pages + /api (auth, trpc, mcp, cron, public PDF)
|
||||||
│ ├── app/ # Next.js App Router pages
|
├── src/server/api/ # tRPC routers
|
||||||
│ │ ├── api/ # API routes (better-auth, tRPC)
|
├── src/server/db/ # Drizzle schema + pool
|
||||||
│ │ ├── auth/ # Authentication pages
|
├── src/components/ # UI + domain components
|
||||||
│ │ ├── dashboard/ # Main app pages
|
├── src/lib/ # auth, PDF, email, branding
|
||||||
│ │ │ ├── clients/ # Client management pages
|
├── drizzle/ # SQL migrations
|
||||||
│ │ │ ├── invoices/ # Invoice management pages
|
└── docs/ # Architecture + UI guides
|
||||||
│ │ │ └── businesses/ # Business profile pages
|
|
||||||
│ │ └── _components/ # Page-specific components
|
|
||||||
│ ├── components/ # Shared UI components
|
|
||||||
│ │ ├── ui/ # shadcn/ui components
|
|
||||||
│ │ ├── data/ # Data display components
|
|
||||||
│ │ ├── forms/ # Form components
|
|
||||||
│ │ └── layout/ # Layout components
|
|
||||||
│ ├── server/ # Server-side code
|
|
||||||
│ │ ├── api/ # tRPC routers
|
|
||||||
│ │ └── db/ # Database schema and connection
|
|
||||||
│ ├── lib/ # Utilities (auth, pdf export, etc.)
|
|
||||||
│ ├── styles/ # Global styles
|
|
||||||
│ └── trpc/ # tRPC client configuration
|
|
||||||
├── drizzle/ # Database migrations
|
|
||||||
├── public/ # Static assets
|
|
||||||
├── docs/ # Documentation
|
|
||||||
├── docker-compose.yml # Deployment compose stack
|
|
||||||
└── docker-compose.dev.yml # Development overrides with exposed PostgreSQL
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎯 Usage
|
## 🎯 Usage
|
||||||
@@ -250,15 +249,14 @@ The application uses the following core tables:
|
|||||||
- **invoices** - Invoice headers with client and business relationships
|
- **invoices** - Invoice headers with client and business relationships
|
||||||
- **invoice_items** - Individual line items with pricing and position ordering
|
- **invoice_items** - Individual line items with pricing and position ordering
|
||||||
|
|
||||||
### API Development
|
### API surface
|
||||||
|
|
||||||
All API endpoints are built with tRPC for type safety:
|
- **tRPC** — `/api/trpc` — primary API for web and mobile (session cookies)
|
||||||
|
- **MCP** — `/api/mcp` — JSON-RPC tools for integrations (API key only)
|
||||||
|
- **REST auth** — `/api/auth/register`, forgot/reset password (mobile + custom flows)
|
||||||
|
- **Public** — `/i/[token]`, `/api/i/[token]/pdf`
|
||||||
|
|
||||||
- **Authentication**: better-auth integration (email/password + OIDC)
|
All business logic lives in `src/server/api/routers/`. Input validation via Zod.
|
||||||
- **Clients**: CRUD operations for client management
|
|
||||||
- **Businesses**: Business profile management
|
|
||||||
- **Invoices**: Invoice creation, management, and status tracking
|
|
||||||
- **Validation**: Zod schemas for input validation
|
|
||||||
|
|
||||||
## 🎨 Customization
|
## 🎨 Customization
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# beenvoice server architecture
|
||||||
|
|
||||||
|
Dense reference for the Next.js web application and API in `beenvoice/`. Package manager: **Bun**. Database: **PostgreSQL** via Drizzle ORM.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Framework | Next.js 16 App Router (`src/app/`) |
|
||||||
|
| API | tRPC 11 (`/api/trpc`), SuperJSON transformer |
|
||||||
|
| ORM | Drizzle + `pg` pool |
|
||||||
|
| Auth | better-auth (email/password, optional Authentik OIDC, Expo plugin for mobile) |
|
||||||
|
| UI | shadcn/ui, Tailwind CSS v4, Radix primitives |
|
||||||
|
| Email | Resend |
|
||||||
|
| PDF | `@react-pdf/renderer` |
|
||||||
|
|
||||||
|
## Request flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser / Mobile / MCP client
|
||||||
|
│
|
||||||
|
├─► /api/auth/* → better-auth handler (session cookies)
|
||||||
|
├─► /api/trpc/* → createContext() → appRouter
|
||||||
|
│ ├─ Bearer / x-api-key → api-key auth
|
||||||
|
│ └─ else → better-auth session
|
||||||
|
├─► /api/mcp → API key only → JSON-RPC tools → tRPC caller
|
||||||
|
├─► /api/i/[token]/pdf → public invoice PDF
|
||||||
|
└─► /dashboard/* → RSC + client components (session required in UI)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Context** (`src/server/api/trpc.ts`): `protectedProcedure` requires `ctx.session.user`. API-key auth sets `authSource: "api-key"`; `apiKeys.*` mutations require session (cannot manage keys with a key).
|
||||||
|
|
||||||
|
## Directory layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Routes (pages + route handlers)
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── auth/ # better-auth catch-all + custom register/reset REST
|
||||||
|
│ │ ├── trpc/[trpc]/ # tRPC HTTP adapter
|
||||||
|
│ │ ├── mcp/ # MCP over HTTP (API key)
|
||||||
|
│ │ ├── i/[token]/pdf/ # Public PDF
|
||||||
|
│ │ └── cron/ # Recurring invoice generation (CRON_SECRET)
|
||||||
|
│ ├── auth/ # sign-in, register, forgot/reset password
|
||||||
|
│ ├── dashboard/ # Authenticated app shell
|
||||||
|
│ └── i/[token]/ # Public invoice view
|
||||||
|
├── components/ # Shared UI (ui/, forms/, layout/, data/)
|
||||||
|
├── hooks/
|
||||||
|
├── lib/ # auth.ts, pdf-export, email templates, branding
|
||||||
|
├── server/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── root.ts # appRouter composition
|
||||||
|
│ │ ├── trpc.ts # procedures, context, timing middleware (dev)
|
||||||
|
│ │ ├── api-keys.ts
|
||||||
|
│ │ └── routers/ # one file per domain
|
||||||
|
│ └── db/
|
||||||
|
│ ├── schema.ts # all tables (prefix beenvoice_)
|
||||||
|
│ ├── index.ts # drizzle + pool
|
||||||
|
│ └── migrate.ts
|
||||||
|
├── trpc/ # react.tsx (client), server.ts (RSC)
|
||||||
|
├── env.js # @t3-oss/env-nextjs validation
|
||||||
|
└── styles/globals.css
|
||||||
|
drizzle/ # SQL migrations (0000–0014+)
|
||||||
|
```
|
||||||
|
|
||||||
|
## tRPC routers
|
||||||
|
|
||||||
|
Root: `src/server/api/root.ts`. All routers use Zod input validation.
|
||||||
|
|
||||||
|
| Namespace | File | Key procedures |
|
||||||
|
|-----------|------|----------------|
|
||||||
|
| `clients` | `routers/clients.ts` | getAll, getById, create, update, delete |
|
||||||
|
| `businesses` | `routers/businesses.ts` | getAll, getById, getDefault, create, update, delete, setDefault, getEmailConfig, updateEmailConfig |
|
||||||
|
| `invoices` | `routers/invoices.ts` | getAll, getBillable, getById, create, update, delete, updateStatus, bulk*, previewPdf, public token, **getByPublicToken** (public), sendReminder |
|
||||||
|
| `payments` | `routers/payments.ts` | getByInvoice, create, delete |
|
||||||
|
| `expenses` | `routers/expenses.ts` | getAll, getById, create, update, delete |
|
||||||
|
| `invoiceTemplates` | `routers/invoiceTemplates.ts` | CRUD by template type |
|
||||||
|
| `recurringInvoices` | `routers/recurring-invoices.ts` | CRUD, pause/resume, generateNow; cron helper `generateDueRecurringInvoices` |
|
||||||
|
| `timeEntries` | `routers/time-entries.ts` | getAll, getRunning, clockIn, updateRunning, clockOut, create, update, delete, getSummary |
|
||||||
|
| `dashboard` | `routers/dashboard.ts` | getStats |
|
||||||
|
| `email` | `routers/email.ts` | sendInvoice |
|
||||||
|
| `settings` | `routers/settings.ts` | profile, theme, animation prefs, export/import data, admin account roles |
|
||||||
|
| `apiKeys` | `routers/apiKeys.ts` | list, create, revoke (session-only) |
|
||||||
|
|
||||||
|
### Time clock semantics
|
||||||
|
|
||||||
|
- **One running entry per user** — partial unique index on `(createdById)` where `endedAt IS NULL`.
|
||||||
|
- `clockIn` — optional client, invoice, rate, backdated `startedAt`; resolves rate from input → client default → business default.
|
||||||
|
- `clockOut` — optional description update; computes hours; if `invoiceId` set, appends line item; else tries latest open invoice for client.
|
||||||
|
- Outcomes: `linked_to_invoice`, `saved_no_invoice`, `saved_no_client`, `zero_hours`.
|
||||||
|
|
||||||
|
## Database schema
|
||||||
|
|
||||||
|
Single file: `src/server/db/schema.ts`. Table names use `pgTableCreator` → prefix `beenvoice_`.
|
||||||
|
|
||||||
|
### Auth & platform
|
||||||
|
|
||||||
|
| Table | Notes |
|
||||||
|
|-------|-------|
|
||||||
|
| `beenvoice_user` | Core user; role for admin features |
|
||||||
|
| `beenvoice_account` | OAuth/credential accounts (better-auth) |
|
||||||
|
| `beenvoice_session` | Sessions; unique token |
|
||||||
|
| `beenvoice_verification_token` | Email verification / reset |
|
||||||
|
| `beenvoice_api_key` | `bv_` prefix keys; SHA-256 hash stored |
|
||||||
|
| `beenvoice_sso_provider` | OIDC/SAML config per user |
|
||||||
|
| `beenvoice_platform_setting` | Singleton (`id = global`) branding/PDF/appearance |
|
||||||
|
|
||||||
|
### Domain
|
||||||
|
|
||||||
|
| Table | FKs | Notes |
|
||||||
|
|-------|-----|-------|
|
||||||
|
| `beenvoice_client` | `createdById` → user | defaultHourlyRate, currency |
|
||||||
|
| `beenvoice_business` | `createdById` | Resend config, `isDefault` |
|
||||||
|
| `beenvoice_invoice` | client, business?, user | status draft/sent/paid; `publicToken` |
|
||||||
|
| `beenvoice_invoice_item` | invoice (cascade) | position ordering |
|
||||||
|
| `beenvoice_invoice_payment` | invoice, user | payment method enum |
|
||||||
|
| `beenvoice_expense` | business?, client?, invoice? | billable flags |
|
||||||
|
| `beenvoice_invoice_template` | user | notes/terms templates |
|
||||||
|
| `beenvoice_recurring_invoice` | client, business?, user | schedule, `nextDueAt` |
|
||||||
|
| `beenvoice_recurring_invoice_item` | recurring (cascade) | |
|
||||||
|
| `beenvoice_time_entry` | client?, invoice?, user | `endedAt` null = running |
|
||||||
|
|
||||||
|
Migrations: `bun run db:generate` → `drizzle/`; apply with `db:push` (dev) or `db:migrate` (prod script).
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
**Server** — `src/lib/auth.ts`:
|
||||||
|
|
||||||
|
- `betterAuth` + `drizzleAdapter` (users, sessions, accounts, verification)
|
||||||
|
- Plugins: `@better-auth/expo` (mobile SecureStore cookies), `nextCookies()`, optional `genericOAuth` (Authentik)
|
||||||
|
- Email/password with bcrypt (12 rounds); `DISABLE_SIGNUPS=true` blocks registration
|
||||||
|
- `trustedOrigins`: production URL, `beenvoice://`, `exp://` (Expo)
|
||||||
|
|
||||||
|
**Web client** — `src/lib/auth-client.ts`: `createAuthClient` + `genericOAuthClient`.
|
||||||
|
|
||||||
|
**Routes**:
|
||||||
|
|
||||||
|
- `src/app/api/auth/[...all]/route.ts` — better-auth handler
|
||||||
|
- Custom REST: `register`, `forgot-password`, `reset-password`, `validate-reset-token` (used by mobile and legacy flows)
|
||||||
|
|
||||||
|
**Session cookies**: `better-auth.session_token` or `__Secure-better-auth.session_token` in production.
|
||||||
|
|
||||||
|
## Mobile API contract
|
||||||
|
|
||||||
|
The Expo app (`beenvoice-app`) does **not** use API keys. It:
|
||||||
|
|
||||||
|
1. Calls the same tRPC endpoints with `Authorization` cookie header from `authClient.getCookie()`.
|
||||||
|
2. Stores session per account in SecureStore via `@better-auth/expo` (`storagePrefix`: `beenvoice:guest` or `beenvoice:auth:{accountId}`).
|
||||||
|
3. Requires `trustedOrigins` and matching `BETTER_AUTH_URL` for the host the device can reach.
|
||||||
|
|
||||||
|
Ensure `src/lib/auth.ts` keeps the `expo()` plugin enabled.
|
||||||
|
|
||||||
|
## MCP (machine clients)
|
||||||
|
|
||||||
|
`POST /api/mcp` — JSON-RPC 2.0, protocol `2025-11-25`.
|
||||||
|
|
||||||
|
- **Auth**: API key only (`Authorization: Bearer bv_…` or `x-api-key`). Session cookies rejected.
|
||||||
|
- **Tools**: ~50 tools mirroring tRPC (invoices, clients, time clock, expenses, etc.)
|
||||||
|
- Implemented in `src/app/api/mcp/route.ts`; delegates to `createCaller(createContext)`.
|
||||||
|
|
||||||
|
API keys: format `bv_<base64url>`; stored as SHA-256 hash (`src/server/api/api-keys.ts`).
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
Validated in `src/env.js`. See `.env.example`.
|
||||||
|
|
||||||
|
| Variable | Required | Notes |
|
||||||
|
|----------|----------|-------|
|
||||||
|
| `DATABASE_URL` | yes | PostgreSQL connection string |
|
||||||
|
| `AUTH_SECRET` | prod | `openssl rand -base64 32` |
|
||||||
|
| `BETTER_AUTH_URL` | yes | Public URL of API (no trailing path) |
|
||||||
|
| `NEXT_PUBLIC_APP_URL` | yes | Browser-facing URL |
|
||||||
|
| `DB_DISABLE_SSL` | local | `true` for Docker dev DB |
|
||||||
|
| `RESEND_API_KEY`, `RESEND_DOMAIN` | optional | Email; blank disables send |
|
||||||
|
| `AUTHENTIK_*` | optional | OIDC SSO |
|
||||||
|
| `DISABLE_SIGNUPS` | optional | `true` blocks registration |
|
||||||
|
| `CRON_SECRET` | cron route | Protects `/api/cron/generate-recurring` |
|
||||||
|
| `NEXT_PUBLIC_BRAND_*` | optional | Build-time white-label defaults |
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
| File | Use |
|
||||||
|
|------|-----|
|
||||||
|
| `docker-compose.yml` | Production: `app` + `db` (Postgres internal) |
|
||||||
|
| `docker-compose.dev.yml` | Dev: Postgres only, port `${POSTGRES_PORT:-5432}` |
|
||||||
|
|
||||||
|
App image built from `Dockerfile`; runs `next start` on port 3000.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev # next dev --turbo
|
||||||
|
bun run build # production build
|
||||||
|
bun run db:push # push schema (dev)
|
||||||
|
bun run db:migrate # run migrations
|
||||||
|
bun run db:studio # Drizzle Studio
|
||||||
|
bun run check # eslint + tsc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Public / unauthenticated surfaces
|
||||||
|
|
||||||
|
- `invoices.getByPublicToken` (tRPC publicProcedure)
|
||||||
|
- `/i/[token]` page and `/api/i/[token]/pdf`
|
||||||
|
- Auth REST endpoints for register/reset
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- [forms-guide.md](./forms-guide.md), [UI_UNIFORMITY_GUIDE.md](./UI_UNIFORMITY_GUIDE.md)
|
||||||
|
- [data-table-responsive-guide.md](./data-table-responsive-guide.md)
|
||||||
|
- [email-features.md](./email-features.md)
|
||||||
|
- Mobile companion: `../beenvoice-app/docs/ARCHITECTURE.md`
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# beenvoice documentation
|
||||||
|
|
||||||
|
## Core
|
||||||
|
|
||||||
|
| Document | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Server stack, tRPC routers, schema, auth, MCP, Docker, mobile API contract |
|
||||||
|
| [../README.md](../README.md) | Install, scripts, deployment |
|
||||||
|
|
||||||
|
## UI & product guides
|
||||||
|
|
||||||
|
| Document | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| [forms-guide.md](./forms-guide.md) | Form patterns |
|
||||||
|
| [UI_UNIFORMITY_GUIDE.md](./UI_UNIFORMITY_GUIDE.md) | Visual consistency |
|
||||||
|
| [breadcrumbs-guide.md](./breadcrumbs-guide.md) | Navigation breadcrumbs |
|
||||||
|
| [data-table-responsive-guide.md](./data-table-responsive-guide.md) | Responsive tables |
|
||||||
|
| [data-table-improvements.md](./data-table-improvements.md) | Table enhancements |
|
||||||
|
| [RESPONSIVE_TABLE_EXAMPLES.md](./RESPONSIVE_TABLE_EXAMPLES.md) | Table examples |
|
||||||
|
| [email-features.md](./email-features.md) | Email composer / delivery |
|
||||||
|
|
||||||
|
## Mobile
|
||||||
|
|
||||||
|
| Document | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| [../../beenvoice-app/docs/ARCHITECTURE.md](../../beenvoice-app/docs/ARCHITECTURE.md) | Expo app architecture |
|
||||||
|
| [../../beenvoice-app/README.md](../../beenvoice-app/README.md) | Mobile setup |
|
||||||
|
|
||||||
|
## Workspace
|
||||||
|
|
||||||
|
| Document | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| [../../README.md](../../README.md) | Meta repo layout, full-stack quick start |
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
-- App Store review demo account: demo@example.com / demo123
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
demo_user_id varchar(255) := 'a0000000-0000-4000-8000-000000000001';
|
||||||
|
demo_account_id text := 'a0000000-0000-4000-8000-000000000010';
|
||||||
|
demo_business_id varchar(255) := 'a0000000-0000-4000-8000-000000000020';
|
||||||
|
demo_client_acme varchar(255) := 'a0000000-0000-4000-8000-000000000030';
|
||||||
|
demo_client_bright varchar(255) := 'a0000000-0000-4000-8000-000000000031';
|
||||||
|
demo_invoice_draft varchar(255) := 'a0000000-0000-4000-8000-000000000040';
|
||||||
|
demo_invoice_sent varchar(255) := 'a0000000-0000-4000-8000-000000000041';
|
||||||
|
demo_invoice_paid varchar(255) := 'a0000000-0000-4000-8000-000000000042';
|
||||||
|
demo_password_hash text := '$2b$12$90U31okgkhOwSQD5RDqHwO0QpcC.pkKsqKb1IPnHfKUZm/2A9hzs6';
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM "beenvoice_user" WHERE "email" = 'demo@example.com') THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO "beenvoice_user" (
|
||||||
|
"id", "name", "email", "emailVerified", "password", "role"
|
||||||
|
) VALUES (
|
||||||
|
demo_user_id,
|
||||||
|
'Demo User',
|
||||||
|
'demo@example.com',
|
||||||
|
true,
|
||||||
|
demo_password_hash,
|
||||||
|
'user'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO "beenvoice_account" (
|
||||||
|
"id", "userId", "accountId", "providerId", "password"
|
||||||
|
) VALUES (
|
||||||
|
demo_account_id,
|
||||||
|
demo_user_id,
|
||||||
|
demo_user_id,
|
||||||
|
'credential',
|
||||||
|
demo_password_hash
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO "beenvoice_business" (
|
||||||
|
"id", "name", "nickname", "email", "phone", "addressLine1", "city", "state",
|
||||||
|
"postalCode", "country", "isDefault", "createdById"
|
||||||
|
) VALUES (
|
||||||
|
demo_business_id,
|
||||||
|
'Demo Studio LLC',
|
||||||
|
'Demo Studio',
|
||||||
|
'hello@demostudio.example',
|
||||||
|
'(555) 010-2000',
|
||||||
|
'100 Market Street',
|
||||||
|
'San Francisco',
|
||||||
|
'CA',
|
||||||
|
'94105',
|
||||||
|
'United States',
|
||||||
|
true,
|
||||||
|
demo_user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO "beenvoice_client" (
|
||||||
|
"id", "name", "email", "phone", "defaultHourlyRate", "currency", "createdById"
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
demo_client_acme,
|
||||||
|
'Acme Corporation',
|
||||||
|
'billing@acme.example',
|
||||||
|
'(555) 010-3001',
|
||||||
|
150,
|
||||||
|
'USD',
|
||||||
|
demo_user_id
|
||||||
|
),
|
||||||
|
(
|
||||||
|
demo_client_bright,
|
||||||
|
'Bright Labs',
|
||||||
|
'ap@brightlabs.example',
|
||||||
|
'(555) 010-3002',
|
||||||
|
125,
|
||||||
|
'USD',
|
||||||
|
demo_user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO "beenvoice_invoice" (
|
||||||
|
"id", "invoiceNumber", "invoicePrefix", "businessId", "clientId",
|
||||||
|
"issueDate", "dueDate", "status", "totalAmount", "taxRate", "notes", "currency", "createdById"
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
demo_invoice_draft,
|
||||||
|
'INV-DEMO-001',
|
||||||
|
'#',
|
||||||
|
demo_business_id,
|
||||||
|
demo_client_acme,
|
||||||
|
NOW() - INTERVAL '5 days',
|
||||||
|
NOW() + INTERVAL '25 days',
|
||||||
|
'draft',
|
||||||
|
1500,
|
||||||
|
0,
|
||||||
|
'Website redesign — phase 1',
|
||||||
|
'USD',
|
||||||
|
demo_user_id
|
||||||
|
),
|
||||||
|
(
|
||||||
|
demo_invoice_sent,
|
||||||
|
'INV-DEMO-002',
|
||||||
|
'#',
|
||||||
|
demo_business_id,
|
||||||
|
demo_client_bright,
|
||||||
|
NOW() - INTERVAL '20 days',
|
||||||
|
NOW() - INTERVAL '5 days',
|
||||||
|
'sent',
|
||||||
|
2500,
|
||||||
|
0,
|
||||||
|
'Mobile app consulting',
|
||||||
|
'USD',
|
||||||
|
demo_user_id
|
||||||
|
),
|
||||||
|
(
|
||||||
|
demo_invoice_paid,
|
||||||
|
'INV-DEMO-003',
|
||||||
|
'#',
|
||||||
|
demo_business_id,
|
||||||
|
demo_client_acme,
|
||||||
|
NOW() - INTERVAL '45 days',
|
||||||
|
NOW() - INTERVAL '15 days',
|
||||||
|
'paid',
|
||||||
|
3200,
|
||||||
|
0,
|
||||||
|
'API integration project',
|
||||||
|
'USD',
|
||||||
|
demo_user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO "beenvoice_invoice_item" (
|
||||||
|
"id", "invoiceId", "date", "description", "hours", "rate", "amount", "position"
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
'a0000000-0000-4000-8000-000000000050',
|
||||||
|
demo_invoice_draft,
|
||||||
|
NOW() - INTERVAL '5 days',
|
||||||
|
'UX wireframes and design system',
|
||||||
|
10,
|
||||||
|
150,
|
||||||
|
1500,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'a0000000-0000-4000-8000-000000000051',
|
||||||
|
demo_invoice_sent,
|
||||||
|
NOW() - INTERVAL '20 days',
|
||||||
|
'Sprint planning and implementation',
|
||||||
|
20,
|
||||||
|
125,
|
||||||
|
2500,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'a0000000-0000-4000-8000-000000000052',
|
||||||
|
demo_invoice_paid,
|
||||||
|
NOW() - INTERVAL '45 days',
|
||||||
|
'Backend API work',
|
||||||
|
16,
|
||||||
|
150,
|
||||||
|
2400,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'a0000000-0000-4000-8000-000000000053',
|
||||||
|
demo_invoice_paid,
|
||||||
|
NOW() - INTERVAL '44 days',
|
||||||
|
'Deployment and documentation',
|
||||||
|
5.33,
|
||||||
|
150,
|
||||||
|
800,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "sendReminderAt" timestamp;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 0015 may have been recorded before the column name was corrected (send_reminder_at vs sendReminderAt).
|
||||||
|
ALTER TABLE "beenvoice_invoice" DROP COLUMN IF EXISTS "send_reminder_at";
|
||||||
|
ALTER TABLE "beenvoice_invoice" ADD COLUMN IF NOT EXISTS "sendReminderAt" timestamp;
|
||||||
@@ -99,6 +99,27 @@
|
|||||||
"when": 1781194385000,
|
"when": 1781194385000,
|
||||||
"tag": "0013_invoice_public_token_expiry",
|
"tag": "0013_invoice_public_token_expiry",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781300000000,
|
||||||
|
"tag": "0014_seed_demo_account",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781400000000,
|
||||||
|
"tag": "0015_invoice_send_reminder_at",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781500000000,
|
||||||
|
"tag": "0016_fix_send_reminder_at_column",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { brand } from "~/lib/branding";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
template: `%s | ${brand.name}`,
|
||||||
|
default: `Legal | ${brand.name}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LegalLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -1,390 +1,21 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import type { Metadata } from "next";
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import Link from "next/link";
|
import { PrivacyPolicyContent } from "~/components/legal/privacy-policy-content";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { LegalPageShell } from "~/components/legal/legal-page-shell";
|
||||||
|
import { brand } from "~/lib/branding";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: `Privacy Policy | ${brand.name}`,
|
||||||
|
description: `How ${brand.name} collects, uses, and protects your data.`,
|
||||||
|
};
|
||||||
|
|
||||||
export default function PrivacyPolicyPage() {
|
export default function PrivacyPolicyPage() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background min-h-screen">
|
<LegalPageShell
|
||||||
{/* Header */}
|
title="Privacy Policy"
|
||||||
<div className="bg-card border-b">
|
description={`How ${brand.name} collects, uses, and protects your data across the web and mobile apps.`}
|
||||||
<div className="container mx-auto max-w-4xl px-6 py-6">
|
>
|
||||||
<div className="flex items-center space-x-4">
|
<PrivacyPolicyContent />
|
||||||
<Link href="/auth/signin">
|
</LegalPageShell>
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Privacy Policy</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Last updated: {new Date().toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="container mx-auto max-w-4xl px-6 py-8">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Introduction</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
beenvoice ("we", "our", or "us")
|
|
||||||
is committed to protecting your privacy. This Privacy Policy
|
|
||||||
explains how we collect, use, disclose, and safeguard your
|
|
||||||
information when you use our invoicing platform and services.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Please read this Privacy Policy carefully. If you do not agree
|
|
||||||
with the terms of this Privacy Policy, please do not access or
|
|
||||||
use our Service.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Information We Collect</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<h4>Personal Information</h4>
|
|
||||||
<p>
|
|
||||||
We may collect personal information that you voluntarily provide
|
|
||||||
to us when you:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Register for an account</li>
|
|
||||||
<li>Create invoices or manage client information</li>
|
|
||||||
<li>Contact us for support</li>
|
|
||||||
<li>Subscribe to our newsletters or communications</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>This personal information may include:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Name and contact information (email, phone, address)</li>
|
|
||||||
<li>Business information and tax details</li>
|
|
||||||
<li>Client information you input into the system</li>
|
|
||||||
<li>Financial information related to your invoices</li>
|
|
||||||
<li>
|
|
||||||
Payment information (processed securely by third-party
|
|
||||||
providers)
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>Automatically Collected Information</h4>
|
|
||||||
<p>
|
|
||||||
We may automatically collect certain information when you visit
|
|
||||||
our Service:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
Device information (IP address, browser type, operating
|
|
||||||
system)
|
|
||||||
</li>
|
|
||||||
<li>Usage data (pages visited, time spent, features used)</li>
|
|
||||||
<li>Log files and analytics data</li>
|
|
||||||
<li>Cookies and similar tracking technologies</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>How We Use Your Information</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>We use the information we collect to:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Provide, operate, and maintain our Service</li>
|
|
||||||
<li>Process your transactions and manage your account</li>
|
|
||||||
<li>Improve and personalize your experience</li>
|
|
||||||
<li>
|
|
||||||
Communicate with you about your account and our services
|
|
||||||
</li>
|
|
||||||
<li>Send you technical notices and support messages</li>
|
|
||||||
<li>Respond to your comments, questions, and requests</li>
|
|
||||||
<li>Monitor usage and analyze trends</li>
|
|
||||||
<li>
|
|
||||||
Detect, prevent, and address technical issues and security
|
|
||||||
breaches
|
|
||||||
</li>
|
|
||||||
<li>Comply with legal obligations</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>How We Share Your Information</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
We do not sell, trade, or rent your personal information to
|
|
||||||
third parties. We may share your information in the following
|
|
||||||
circumstances:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Service Providers</h4>
|
|
||||||
<p>
|
|
||||||
We may share your information with trusted third-party service
|
|
||||||
providers who assist us in operating our Service, such as:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Cloud hosting and storage providers</li>
|
|
||||||
<li>Payment processors</li>
|
|
||||||
<li>Email service providers</li>
|
|
||||||
<li>Analytics and monitoring services</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>Legal Requirements</h4>
|
|
||||||
<p>
|
|
||||||
We may disclose your information if required to do so by law or
|
|
||||||
in response to:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Legal processes (subpoenas, court orders)</li>
|
|
||||||
<li>Government requests</li>
|
|
||||||
<li>Law enforcement investigations</li>
|
|
||||||
<li>Protection of our rights, property, or safety</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>Business Transfers</h4>
|
|
||||||
<p>
|
|
||||||
In the event of a merger, acquisition, or sale of assets, your
|
|
||||||
information may be transferred as part of that transaction.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Data Security</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
We implement appropriate technical and organizational security
|
|
||||||
measures to protect your information:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Encryption of data in transit and at rest</li>
|
|
||||||
<li>Secure access controls and authentication</li>
|
|
||||||
<li>Regular security assessments and updates</li>
|
|
||||||
<li>Employee training on data protection</li>
|
|
||||||
<li>Incident response procedures</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
However, no method of transmission over the internet or
|
|
||||||
electronic storage is 100% secure. While we strive to protect
|
|
||||||
your information, we cannot guarantee absolute security.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Data Retention</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
We retain your personal information only for as long as
|
|
||||||
necessary to fulfill the purposes outlined in this Privacy
|
|
||||||
Policy, unless a longer retention period is required by law.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Factors we consider when determining retention periods include:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>The nature and sensitivity of the information</li>
|
|
||||||
<li>Legal and regulatory requirements</li>
|
|
||||||
<li>Business and operational needs</li>
|
|
||||||
<li>Your account status and activity</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Your Rights and Choices</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
Depending on your location, you may have the following rights
|
|
||||||
regarding your personal information:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Access and Portability</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Request access to your personal information</li>
|
|
||||||
<li>Receive a copy of your data in a portable format</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>Correction and Updates</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Correct inaccurate or incomplete information</li>
|
|
||||||
<li>Update your account information at any time</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>Deletion</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Request deletion of your personal information</li>
|
|
||||||
<li>Close your account and remove your data</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>Restriction and Objection</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Restrict the processing of your information</li>
|
|
||||||
<li>Object to certain uses of your data</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
To exercise these rights, please contact us using the
|
|
||||||
information provided in the "Contact Us" section
|
|
||||||
below.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Cookies and Tracking Technologies</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>We use cookies and similar technologies to:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Remember your preferences and settings</li>
|
|
||||||
<li>Authenticate your account</li>
|
|
||||||
<li>Analyze usage patterns and improve our Service</li>
|
|
||||||
<li>Provide personalized content and features</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
You can control cookies through your browser settings. However,
|
|
||||||
disabling cookies may affect the functionality of our Service.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Types of Cookies We Use</h4>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<strong>Essential Cookies:</strong> Required for the Service
|
|
||||||
to function properly
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Analytics Cookies:</strong> Help us understand how you
|
|
||||||
use our Service
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Preference Cookies:</strong> Remember your settings
|
|
||||||
and preferences
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Third-Party Links and Services</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
Our Service may contain links to third-party websites or
|
|
||||||
integrate with third-party services. We are not responsible for
|
|
||||||
the privacy practices of these third parties.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
We encourage you to read the privacy policies of any third-party
|
|
||||||
services you use in connection with our Service.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Children's Privacy</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
Our Service is not intended for children under the age of 13. We
|
|
||||||
do not knowingly collect personal information from children
|
|
||||||
under 13.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If you are a parent or guardian and believe your child has
|
|
||||||
provided us with personal information, please contact us
|
|
||||||
immediately so we can remove such information.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>International Data Transfers</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
Your information may be transferred to and processed in
|
|
||||||
countries other than your own. We ensure that such transfers
|
|
||||||
comply with applicable data protection laws.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
When we transfer your information internationally, we implement
|
|
||||||
appropriate safeguards to protect your data, including:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Standard contractual clauses</li>
|
|
||||||
<li>Adequacy decisions by relevant authorities</li>
|
|
||||||
<li>Certified privacy frameworks</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Changes to This Privacy Policy</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
We may update this Privacy Policy from time to time. We will
|
|
||||||
notify you of any material changes by:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Posting the updated policy on our Service</li>
|
|
||||||
<li>Sending you an email notification</li>
|
|
||||||
<li>Displaying a prominent notice on our Service</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
Your continued use of our Service after any changes indicates
|
|
||||||
your acceptance of the updated Privacy Policy.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Contact Us</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
If you have questions about this Privacy Policy or our privacy
|
|
||||||
practices, please contact us at:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Email: privacy@beenvoice.com</li>
|
|
||||||
<li>Address: [Your Business Address]</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
We will respond to your inquiries within a reasonable timeframe
|
|
||||||
and in accordance with applicable law.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-302
@@ -1,307 +1,21 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import type { Metadata } from "next";
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import Link from "next/link";
|
import { LegalPageShell } from "~/components/legal/legal-page-shell";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { TermsOfServiceContent } from "~/components/legal/terms-of-service-content";
|
||||||
|
import { brand } from "~/lib/branding";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: `Terms of Service | ${brand.name}`,
|
||||||
|
description: `Terms governing your use of the ${brand.name} platform.`,
|
||||||
|
};
|
||||||
|
|
||||||
export default function TermsOfServicePage() {
|
export default function TermsOfServicePage() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background min-h-screen">
|
<LegalPageShell
|
||||||
{/* Header */}
|
title="Terms of Service"
|
||||||
<div className="bg-card border-b">
|
description={`The rules for using ${brand.name} on the web and mobile apps.`}
|
||||||
<div className="container mx-auto max-w-4xl px-6 py-6">
|
>
|
||||||
<div className="flex items-center space-x-4">
|
<TermsOfServiceContent />
|
||||||
<Link href="/auth/signin">
|
</LegalPageShell>
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Terms of Service</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Last updated: {new Date().toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="container mx-auto max-w-4xl px-6 py-8">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Agreement to Terms</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
These Terms of Service ("Terms") govern your use of
|
|
||||||
the beenvoice platform and services (the "Service")
|
|
||||||
operated by beenvoice ("us", "we", or
|
|
||||||
"our").
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
By accessing or using our Service, you agree to be bound by
|
|
||||||
these Terms. If you disagree with any part of these terms, then
|
|
||||||
you may not access the Service.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Description of Service</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
beenvoice is a web-based invoicing platform that allows users
|
|
||||||
to:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Create and manage professional invoices</li>
|
|
||||||
<li>Track client information and billing details</li>
|
|
||||||
<li>Monitor payment status and financial metrics</li>
|
|
||||||
<li>Generate reports and analytics</li>
|
|
||||||
<li>Manage business profiles and settings</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>User Accounts</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
When you create an account with us, you must provide information
|
|
||||||
that is accurate, complete, and current at all times. You are
|
|
||||||
responsible for safeguarding the password and for all activities
|
|
||||||
that occur under your account.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You agree not to disclose your password to any third party. You
|
|
||||||
must notify us immediately upon becoming aware of any breach of
|
|
||||||
security or unauthorized use of your account.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Acceptable Use</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>You agree not to use the Service:</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
For any unlawful purpose or to solicit others to perform
|
|
||||||
unlawful acts
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
To violate any international, federal, provincial, or state
|
|
||||||
regulations, rules, laws, or local ordinances
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
To infringe upon or violate our intellectual property rights
|
|
||||||
or the intellectual property rights of others
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
To harass, abuse, insult, harm, defame, slander, disparage,
|
|
||||||
intimidate, or discriminate
|
|
||||||
</li>
|
|
||||||
<li>To submit false or misleading information</li>
|
|
||||||
<li>
|
|
||||||
To upload or transmit viruses or any other type of malicious
|
|
||||||
code
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
To spam, phish, pharm, pretext, spider, crawl, or scrape
|
|
||||||
</li>
|
|
||||||
<li>For any obscene or immoral purpose</li>
|
|
||||||
<li>
|
|
||||||
To interfere with or circumvent the security features of the
|
|
||||||
Service
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Data and Privacy</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
Your privacy is important to us. Please review our Privacy
|
|
||||||
Policy, which also governs your use of the Service, to
|
|
||||||
understand our practices.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You retain ownership of your data. We will not sell, rent, or
|
|
||||||
share your personal information with third parties without your
|
|
||||||
explicit consent, except as described in our Privacy Policy.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You are responsible for backing up your data. While we implement
|
|
||||||
regular backups, we recommend you maintain your own copies of
|
|
||||||
important information.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Payment Terms</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
Some aspects of the Service may require payment. You will be
|
|
||||||
charged according to your subscription plan. All fees are
|
|
||||||
non-refundable unless otherwise stated.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
We may change our fees at any time. We will provide you with
|
|
||||||
reasonable notice of any fee changes by posting the new fees on
|
|
||||||
the Service or sending you email notification.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If you fail to pay any fees when due, we may suspend or
|
|
||||||
terminate your access to the Service until payment is made.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Intellectual Property Rights</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
The Service and its original content, features, and
|
|
||||||
functionality are and will remain the exclusive property of
|
|
||||||
beenvoice and its licensors. The Service is protected by
|
|
||||||
copyright, trademark, and other laws.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Our trademarks and trade dress may not be used in connection
|
|
||||||
with any product or service without our prior written consent.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Termination</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
We may terminate or suspend your account and bar access to the
|
|
||||||
Service immediately, without prior notice or liability, under
|
|
||||||
our sole discretion, for any reason whatsoever and without
|
|
||||||
limitation, including but not limited to a breach of the Terms.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If you wish to terminate your account, you may simply
|
|
||||||
discontinue using the Service and contact us to request account
|
|
||||||
deletion.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Upon termination, your right to use the Service will cease
|
|
||||||
immediately. If you wish to terminate your account, you may
|
|
||||||
simply discontinue using the Service.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Disclaimer of Warranties</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
The information on this Service is provided on an "as
|
|
||||||
is" basis. To the fullest extent permitted by law, we
|
|
||||||
exclude all representations, warranties, and conditions relating
|
|
||||||
to our Service and the use of this Service.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Nothing in this disclaimer will limit or exclude our or your
|
|
||||||
liability for death or personal injury resulting from
|
|
||||||
negligence, fraud, or fraudulent misrepresentation.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Limitation of Liability</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
In no event shall beenvoice, nor its directors, employees,
|
|
||||||
partners, agents, suppliers, or affiliates, be liable for any
|
|
||||||
indirect, incidental, special, consequential, or punitive
|
|
||||||
damages, including without limitation, loss of profits, data,
|
|
||||||
use, goodwill, or other intangible losses, resulting from your
|
|
||||||
use of the Service.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Governing Law</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
These Terms shall be interpreted and governed by the laws of the
|
|
||||||
jurisdiction in which beenvoice operates, without regard to its
|
|
||||||
conflict of law provisions.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Our failure to enforce any right or provision of these Terms
|
|
||||||
will not be considered a waiver of those rights.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Changes to Terms</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
We reserve the right, at our sole discretion, to modify or
|
|
||||||
replace these Terms at any time. If a revision is material, we
|
|
||||||
will provide at least 30 days notice prior to any new terms
|
|
||||||
taking effect.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
What constitutes a material change will be determined at our
|
|
||||||
sole discretion. By continuing to access or use our Service
|
|
||||||
after any revisions become effective, you agree to be bound by
|
|
||||||
the revised terms.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Contact Information</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
If you have any questions about these Terms of Service, please
|
|
||||||
contact us at:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Email: legal@beenvoice.com</li>
|
|
||||||
<li>Address: [Your Business Address]</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
authentik: env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true,
|
||||||
|
signupsDisabled: env.DISABLE_SIGNUPS === true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Label } from "~/components/ui/label";
|
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 { LegalAgreementNotice } from "~/components/legal/legal-links";
|
||||||
import {
|
import {
|
||||||
Mail,
|
Mail,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -347,27 +347,10 @@ function ForgotPasswordForm() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground text-center text-xs leading-relaxed">
|
<LegalAgreementNotice
|
||||||
By using our service, you agree to our{" "}
|
action="using our service"
|
||||||
<LegalModal
|
className="leading-relaxed"
|
||||||
type="terms"
|
/>
|
||||||
trigger={
|
|
||||||
<span className="text-primary inline cursor-pointer hover:underline">
|
|
||||||
Terms of Service
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>{" "}
|
|
||||||
and{" "}
|
|
||||||
<LegalModal
|
|
||||||
type="privacy"
|
|
||||||
trigger={
|
|
||||||
<span className="text-primary inline cursor-pointer hover:underline">
|
|
||||||
Privacy Policy
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Label } from "~/components/ui/label";
|
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 { LegalAgreementNotice } from "~/components/legal/legal-links";
|
||||||
import { Mail, Lock, ArrowRight, User } from "lucide-react";
|
import { Mail, Lock, ArrowRight, User } from "lucide-react";
|
||||||
|
|
||||||
function RegisterForm() {
|
function RegisterForm() {
|
||||||
@@ -151,19 +151,7 @@ function RegisterForm() {
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-center text-xs">
|
<LegalAgreementNotice action="creating an account" />
|
||||||
By creating an account you agree to our{" "}
|
|
||||||
<LegalModal
|
|
||||||
type="terms"
|
|
||||||
trigger={<span className="text-foreground cursor-pointer hover:underline">Terms</span>}
|
|
||||||
/>{" "}
|
|
||||||
and{" "}
|
|
||||||
<LegalModal
|
|
||||||
type="privacy"
|
|
||||||
trigger={<span className="text-foreground cursor-pointer hover:underline">Privacy Policy</span>}
|
|
||||||
/>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Label } from "~/components/ui/label";
|
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 { LegalAgreementNotice } from "~/components/legal/legal-links";
|
||||||
import {
|
import {
|
||||||
Lock,
|
Lock,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -425,27 +425,10 @@ function ResetPasswordForm() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground text-center text-xs leading-relaxed">
|
<LegalAgreementNotice
|
||||||
By resetting your password, you agree to our{" "}
|
action="resetting your password"
|
||||||
<LegalModal
|
className="leading-relaxed"
|
||||||
type="terms"
|
/>
|
||||||
trigger={
|
|
||||||
<span className="text-primary inline cursor-pointer hover:underline">
|
|
||||||
Terms of Service
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>{" "}
|
|
||||||
and{" "}
|
|
||||||
<LegalModal
|
|
||||||
type="privacy"
|
|
||||||
trigger={
|
|
||||||
<span className="text-primary inline cursor-pointer hover:underline">
|
|
||||||
Privacy Policy
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Label } from "~/components/ui/label";
|
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 { LegalAgreementNotice } from "~/components/legal/legal-links";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { Mail, Lock, ArrowRight, Shield } from "lucide-react";
|
import { Mail, Lock, ArrowRight, Shield } from "lucide-react";
|
||||||
|
|
||||||
@@ -167,19 +167,7 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-muted-foreground text-center text-xs">
|
<LegalAgreementNotice action="signing in" />
|
||||||
By signing in you agree to our{" "}
|
|
||||||
<LegalModal
|
|
||||||
type="terms"
|
|
||||||
trigger={<span className="text-foreground cursor-pointer hover:underline">Terms</span>}
|
|
||||||
/>{" "}
|
|
||||||
and{" "}
|
|
||||||
<LegalModal
|
|
||||||
type="privacy"
|
|
||||||
trigger={<span className="text-foreground cursor-pointer hover:underline">Privacy Policy</span>}
|
|
||||||
/>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default async function BusinessDetailPage({
|
|||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<Button asChild variant="outline" className="shadow-sm">
|
<Button asChild variant="outline" className="shadow-sm">
|
||||||
<Link href="/dashboard/businesses">
|
<Link href="/dashboard/entities?tab=businesses">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<span>Back to Businesses</span>
|
<span>Back to Businesses</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
|
import { BusinessesDataTable } from "./businesses-data-table";
|
||||||
|
|
||||||
|
export function BusinessesTable() {
|
||||||
|
const { data: businesses, isLoading } = api.businesses.getAll.useQuery();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <DataTableSkeleton columns={7} rows={5} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!businesses) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BusinessesDataTable businesses={businesses} />;
|
||||||
|
}
|
||||||
@@ -1,40 +1,5 @@
|
|||||||
import { Plus } from "lucide-react";
|
import { redirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { api, HydrateClient } from "~/trpc/server";
|
|
||||||
import { BusinessesDataTable } from "./_components/businesses-data-table";
|
|
||||||
|
|
||||||
// Businesses Table Component
|
export default function BusinessesPage() {
|
||||||
async function BusinessesTable() {
|
redirect("/dashboard/entities?tab=businesses");
|
||||||
const businesses = await api.businesses.getAll();
|
|
||||||
|
|
||||||
return <BusinessesDataTable businesses={businesses} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BusinessesPage() {
|
|
||||||
return (
|
|
||||||
<div className="page-enter space-y-8">
|
|
||||||
<PageHeader
|
|
||||||
title="Businesses"
|
|
||||||
description="Manage your businesses and their information"
|
|
||||||
variant="gradient"
|
|
||||||
>
|
|
||||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
|
||||||
<Link href="/dashboard/businesses/new">
|
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
|
||||||
<span>Add Business</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<HydrateClient>
|
|
||||||
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
|
||||||
<BusinessesTable />
|
|
||||||
</Suspense>
|
|
||||||
</HydrateClient>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default async function ClientDetailPage({
|
|||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<Button asChild variant="outline" className="shadow-sm">
|
<Button asChild variant="outline" className="shadow-sm">
|
||||||
<Link href="/dashboard/clients">
|
<Link href="/dashboard/entities?tab=clients">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<span>Back to Clients</span>
|
<span>Back to Clients</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,29 +1,5 @@
|
|||||||
import { Plus } from "lucide-react";
|
import { redirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { HydrateClient } from "~/trpc/server";
|
|
||||||
import { ClientsTable } from "./_components/clients-table";
|
|
||||||
|
|
||||||
export default async function ClientsPage() {
|
export default function ClientsPage() {
|
||||||
return (
|
redirect("/dashboard/entities?tab=clients");
|
||||||
<div className="page-enter space-y-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Clients"
|
|
||||||
description="Manage your clients and their information."
|
|
||||||
variant="gradient"
|
|
||||||
>
|
|
||||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
|
||||||
<Link href="/dashboard/clients/new">
|
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
|
||||||
<span>Add Client</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<HydrateClient>
|
|
||||||
<ClientsTable />
|
|
||||||
</HydrateClient>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
|
import { ClientsTable } from "../../clients/_components/clients-table";
|
||||||
|
import { BusinessesTable } from "../../businesses/_components/businesses-table";
|
||||||
|
|
||||||
|
type EntityTab = "clients" | "businesses";
|
||||||
|
|
||||||
|
export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const tab: EntityTab =
|
||||||
|
searchParams.get("tab") === "businesses" ? "businesses" : initialTab;
|
||||||
|
|
||||||
|
function handleTabChange(value: string) {
|
||||||
|
const next = value === "businesses" ? "businesses" : "clients";
|
||||||
|
router.replace(`/dashboard/entities?tab=${next}`, { scroll: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const addHref =
|
||||||
|
tab === "clients" ? "/dashboard/clients/new" : "/dashboard/businesses/new";
|
||||||
|
const addLabel = tab === "clients" ? "Add client" : "Add business";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Entities"
|
||||||
|
description="Clients you bill and businesses you send from"
|
||||||
|
variant="gradient"
|
||||||
|
>
|
||||||
|
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||||
|
<Link href={addHref}>
|
||||||
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
|
<span>{addLabel}</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Tabs value={tab} onValueChange={handleTabChange}>
|
||||||
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
|
<TabsTrigger value="clients">Clients</TabsTrigger>
|
||||||
|
<TabsTrigger value="businesses">Businesses</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="clients" className="mt-6">
|
||||||
|
<ClientsTable />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="businesses" className="mt-6">
|
||||||
|
<BusinessesTable />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
|
import { api, HydrateClient } from "~/trpc/server";
|
||||||
|
import { EntitiesView } from "./_components/entities-view";
|
||||||
|
|
||||||
|
export default async function EntitiesPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ tab?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const initialTab = params.tab === "businesses" ? "businesses" : "clients";
|
||||||
|
|
||||||
|
void api.clients.getAll.prefetch();
|
||||||
|
void api.businesses.getAll.prefetch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-enter space-y-6">
|
||||||
|
<HydrateClient>
|
||||||
|
<Suspense fallback={<DataTableSkeleton columns={5} rows={8} />}>
|
||||||
|
<EntitiesView initialTab={initialTab} />
|
||||||
|
</Suspense>
|
||||||
|
</HydrateClient>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ import { Separator } from "~/components/ui/separator";
|
|||||||
import { Textarea } from "~/components/ui/textarea";
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
import {
|
import {
|
||||||
getEffectiveInvoiceStatus,
|
getEffectiveInvoiceStatus,
|
||||||
isInvoiceOverdue,
|
isInvoiceOverdue,
|
||||||
@@ -166,6 +167,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
onError: (e) => toast.error(e.message ?? "Failed to send reminder"),
|
onError: (e) => toast.error(e.message ?? "Failed to send reminder"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateInvoice = api.invoices.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Reminder saved");
|
||||||
|
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||||
|
void utils.dashboard.getStats.invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message ?? "Failed to save reminder"),
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) return <InvoiceDetailsSkeleton />;
|
if (isLoading) return <InvoiceDetailsSkeleton />;
|
||||||
if (!invoice) notFound();
|
if (!invoice) notFound();
|
||||||
|
|
||||||
@@ -522,7 +532,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{effectiveStatus !== "paid" && (
|
{storedStatus === "draft" && (
|
||||||
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
|
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -553,6 +563,25 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{effectiveStatus === "draft" && (
|
||||||
|
<SendReminderEditor
|
||||||
|
key={`${invoiceId}-${invoice.sendReminderAt?.toISOString() ?? "none"}`}
|
||||||
|
invoiceId={invoiceId}
|
||||||
|
savedReminderAt={invoice.sendReminderAt}
|
||||||
|
formatDate={formatDate}
|
||||||
|
isSaving={updateInvoice.isPending}
|
||||||
|
onSave={(sendReminderAt) =>
|
||||||
|
updateInvoice.mutate({
|
||||||
|
id: invoiceId,
|
||||||
|
sendReminderAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onClear={() =>
|
||||||
|
updateInvoice.mutate({ id: invoiceId, sendReminderAt: null })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
|
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
|
||||||
<EnhancedSendInvoiceButton
|
<EnhancedSendInvoiceButton
|
||||||
invoiceId={invoice.id}
|
invoiceId={invoice.id}
|
||||||
@@ -808,6 +837,67 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SendReminderEditor({
|
||||||
|
invoiceId,
|
||||||
|
savedReminderAt,
|
||||||
|
formatDate,
|
||||||
|
isSaving,
|
||||||
|
onSave,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
invoiceId: string;
|
||||||
|
savedReminderAt: Date | null | undefined;
|
||||||
|
formatDate: (date: Date) => string;
|
||||||
|
isSaving: boolean;
|
||||||
|
onSave: (sendReminderAt: Date | null) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const [sendReminderAt, setSendReminderAt] = useState<Date | undefined>(() =>
|
||||||
|
savedReminderAt ? new Date(savedReminderAt) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-lg border p-3">
|
||||||
|
<Label htmlFor={`send-reminder-at-${invoiceId}`}>Remind me to send</Label>
|
||||||
|
<DatePicker
|
||||||
|
date={sendReminderAt}
|
||||||
|
onDateChange={setSendReminderAt}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => onSave(sendReminderAt ?? null)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Save reminder
|
||||||
|
</Button>
|
||||||
|
{sendReminderAt ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSendReminderAt(undefined);
|
||||||
|
onClear();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{savedReminderAt ? (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{new Date(savedReminderAt) <= new Date()
|
||||||
|
? "Reminder is due — time to send this invoice."
|
||||||
|
: `Scheduled for ${formatDate(savedReminderAt)}`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function InvoiceViewPage() {
|
export default function InvoiceViewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { useAuthSession } from "~/hooks/use-auth-session";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -84,6 +85,7 @@ import {
|
|||||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||||
import {
|
import {
|
||||||
bodyFontPreferences,
|
bodyFontPreferences,
|
||||||
|
brand,
|
||||||
colorModes,
|
colorModes,
|
||||||
colorThemes,
|
colorThemes,
|
||||||
type ColorTheme,
|
type ColorTheme,
|
||||||
@@ -701,6 +703,27 @@ export function SettingsContent() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card className="bg-card border-border border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground flex items-center gap-2">
|
||||||
|
<FileText className="text-primary h-5 w-5" />
|
||||||
|
Legal
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Review how we handle your data and the terms for using{" "}
|
||||||
|
{brand.name}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/terms">Terms of Service</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/privacy">Privacy Policy</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="preferences" className="space-y-8">
|
<TabsContent value="preferences" className="space-y-8">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default async function TimeClockPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter mx-auto max-w-3xl space-y-6">
|
<div className="page-enter space-y-6">
|
||||||
<DashboardPageHeader
|
<DashboardPageHeader
|
||||||
title="Time clock"
|
title="Time clock"
|
||||||
description="Track billable hours and save them directly to an invoice"
|
description="Track billable hours and save them directly to an invoice"
|
||||||
|
|||||||
+2
-2
@@ -97,10 +97,10 @@ export default function HomePage() {
|
|||||||
<span>© 2026 {brand.name}</span>
|
<span>© 2026 {brand.name}</span>
|
||||||
<div className="flex gap-5">
|
<div className="flex gap-5">
|
||||||
<Link href="/privacy" className="hover:text-foreground">
|
<Link href="/privacy" className="hover:text-foreground">
|
||||||
Privacy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/terms" className="hover:text-foreground">
|
<Link href="/terms" className="hover:text-foreground">
|
||||||
Terms
|
Terms of Service
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast.success("Business created successfully");
|
toast.success("Business created successfully");
|
||||||
router.push("/dashboard/businesses");
|
router.push("/dashboard/entities?tab=businesses");
|
||||||
} else {
|
} else {
|
||||||
// Update business data (excluding email config fields)
|
// Update business data (excluding email config fields)
|
||||||
const businessData = {
|
const businessData = {
|
||||||
@@ -386,7 +386,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast.success("Business updated successfully");
|
toast.success("Business updated successfully");
|
||||||
router.push("/dashboard/businesses");
|
router.push("/dashboard/entities?tab=businesses");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -400,7 +400,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
);
|
);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
}
|
}
|
||||||
router.push("/dashboard/businesses");
|
router.push("/dashboard/entities?tab=businesses");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
const createClient = api.clients.create.useMutation({
|
const createClient = api.clients.create.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Client created successfully");
|
toast.success("Client created successfully");
|
||||||
router.push("/dashboard/clients");
|
router.push("/dashboard/entities?tab=clients");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || "Failed to create client");
|
toast.error(error.message || "Failed to create client");
|
||||||
@@ -109,7 +109,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
const updateClient = api.clients.update.useMutation({
|
const updateClient = api.clients.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Client updated successfully");
|
toast.success("Client updated successfully");
|
||||||
router.push("/dashboard/clients");
|
router.push("/dashboard/entities?tab=clients");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || "Failed to update client");
|
toast.error(error.message || "Failed to update client");
|
||||||
@@ -232,7 +232,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
);
|
);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
}
|
}
|
||||||
router.push("/dashboard/clients");
|
router.push("/dashboard/entities?tab=clients");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === "edit" && isLoadingClient) {
|
if (mode === "edit" && isLoadingClient) {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ interface InvoiceCalendarViewProps {
|
|||||||
onRemoveItem: (index: number) => void;
|
onRemoveItem: (index: number) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
defaultHourlyRate: number | null;
|
defaultHourlyRate: number | null;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvoiceCalendarView({
|
export function InvoiceCalendarView({
|
||||||
@@ -63,6 +64,7 @@ export function InvoiceCalendarView({
|
|||||||
onRemoveItem,
|
onRemoveItem,
|
||||||
className,
|
className,
|
||||||
defaultHourlyRate: _defaultHourlyRate,
|
defaultHourlyRate: _defaultHourlyRate,
|
||||||
|
readOnly = false,
|
||||||
}: InvoiceCalendarViewProps) {
|
}: InvoiceCalendarViewProps) {
|
||||||
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
|
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
|
||||||
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
|
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
|
||||||
@@ -403,10 +405,12 @@ export function InvoiceCalendarView({
|
|||||||
There are no time entries recorded for this day yet.
|
There are no time entries recorded for this day yet.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
|
{!readOnly ? (
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
|
||||||
Log Time
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
Log Time
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -428,6 +432,7 @@ export function InvoiceCalendarView({
|
|||||||
}
|
}
|
||||||
placeholder="Describe the work performed..."
|
placeholder="Describe the work performed..."
|
||||||
className="pl-3 text-sm"
|
className="pl-3 text-sm"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -443,6 +448,7 @@ export function InvoiceCalendarView({
|
|||||||
step={0.25}
|
step={0.25}
|
||||||
min={0}
|
min={0}
|
||||||
width="full"
|
width="full"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -456,6 +462,7 @@ export function InvoiceCalendarView({
|
|||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
width="full"
|
width="full"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -464,15 +471,17 @@ export function InvoiceCalendarView({
|
|||||||
{/* 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
|
{!readOnly ? (
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => onRemoveItem(index)}
|
size="sm"
|
||||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
onClick={() => onRemoveItem(index)}
|
||||||
>
|
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||||
<Trash2 className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 px-3 text-center">
|
<div className="flex-1 px-3 text-center">
|
||||||
<span className="text-muted-foreground block text-sm font-medium">
|
<span className="text-muted-foreground block text-sm font-medium">
|
||||||
@@ -490,16 +499,18 @@ export function InvoiceCalendarView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button
|
{!readOnly ? (
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={handleAddNewItem}
|
variant="outline"
|
||||||
className="hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary group w-full gap-2 rounded-xl border-dashed py-8 transition-all"
|
onClick={handleAddNewItem}
|
||||||
>
|
className="hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary group w-full gap-2 rounded-xl border-dashed py-8 transition-all"
|
||||||
<div className="bg-muted group-hover:bg-primary/10 rounded-md p-1 transition-colors">
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<div className="bg-muted group-hover:bg-primary/10 rounded-md p-1 transition-colors">
|
||||||
</div>
|
<Plus className="h-4 w-4" />
|
||||||
<span>Add Another Entry</span>
|
</div>
|
||||||
</Button>
|
<span>Add Another Entry</span>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
import { STATUS_OPTIONS } from "./invoice/types";
|
import { STATUS_OPTIONS } from "./invoice/types";
|
||||||
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
||||||
import type { ParsedLineItem } from "~/lib/parse-line-item";
|
import type { ParsedLineItem } from "~/lib/parse-line-item";
|
||||||
|
import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel";
|
||||||
|
|
||||||
import { CountUp } from "~/components/ui/count-up";
|
import { CountUp } from "~/components/ui/count-up";
|
||||||
|
|
||||||
@@ -135,6 +136,15 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
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");
|
const [previewTab, setPreviewTab] = useState("pdf");
|
||||||
|
const [previewPinned, setPreviewPinned] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia("(min-width: 1024px)");
|
||||||
|
const update = () => setPreviewPinned(media.matches);
|
||||||
|
update();
|
||||||
|
media.addEventListener("change", update);
|
||||||
|
return () => media.removeEventListener("change", update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Queries (Same as before)
|
// Queries (Same as before)
|
||||||
const { data: clients, isLoading: loadingClients } =
|
const { data: clients, isLoading: loadingClients } =
|
||||||
@@ -254,17 +264,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
[formData],
|
[formData],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: pdfPreview, isFetching: pdfPreviewLoading } =
|
|
||||||
api.invoices.previewPdf.useQuery(pdfPreviewInput, {
|
|
||||||
enabled:
|
|
||||||
activeTab === "preview" &&
|
|
||||||
previewTab === "pdf" &&
|
|
||||||
Boolean(formData.clientId) &&
|
|
||||||
formData.items.length > 0 &&
|
|
||||||
formData.items.every((item) => item.description.trim() !== ""),
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
const selectedClient = React.useMemo(
|
const selectedClient = React.useMemo(
|
||||||
() => clients?.find((client) => client.id === formData.clientId),
|
() => clients?.find((client) => client.id === formData.clientId),
|
||||||
[clients, formData.clientId],
|
[clients, formData.clientId],
|
||||||
@@ -480,9 +479,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
|
||||||
|
<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-4 rounded-xl p-1">
|
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1 lg:grid-cols-3">
|
||||||
<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"
|
||||||
@@ -503,7 +503,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="preview"
|
value="preview"
|
||||||
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 lg:hidden"
|
||||||
>
|
>
|
||||||
Preview
|
Preview
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -808,6 +808,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
|
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
|
||||||
clientId={formData.clientId || undefined}
|
clientId={formData.clientId || undefined}
|
||||||
defaultRate={formData.items[0]?.rate}
|
defaultRate={formData.items[0]?.rate}
|
||||||
|
readOnly={formData.status !== "draft"}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -831,6 +832,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
onRemoveItem={removeItem}
|
onRemoveItem={removeItem}
|
||||||
onUpdateItem={updateItem}
|
onUpdateItem={updateItem}
|
||||||
defaultHourlyRate={formData.defaultHourlyRate}
|
defaultHourlyRate={formData.defaultHourlyRate}
|
||||||
|
readOnly={formData.status !== "draft"}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -861,43 +863,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="pdf" className="mt-6">
|
<TabsContent value="pdf" className="mt-6">
|
||||||
<Card>
|
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
|
||||||
<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>
|
||||||
|
|
||||||
<TabsContent value="email" className="mt-6">
|
<TabsContent value="email" className="mt-6">
|
||||||
@@ -952,6 +918,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<aside className="hidden lg:block">
|
||||||
|
<div className="sticky top-4 space-y-4">
|
||||||
|
<InvoicePdfPreviewPanel
|
||||||
|
input={pdfPreviewInput}
|
||||||
|
enabled={previewPinned || activeTab === "preview"}
|
||||||
|
/>
|
||||||
|
<Card className="border-primary/20 bg-primary/5">
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-muted-foreground text-sm">Invoice total</span>
|
||||||
|
<span className="font-mono text-2xl font-bold">
|
||||||
|
<CountUp value={totals.total} prefix="$" />
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import Link from "next/link";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { DatePicker } from "~/components/ui/date-picker";
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
|
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
|
||||||
@@ -40,6 +39,7 @@ interface InvoiceLineItemsProps {
|
|||||||
clientId?: string;
|
clientId?: string;
|
||||||
defaultRate?: number;
|
defaultRate?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LineItemRowProps {
|
interface LineItemRowProps {
|
||||||
@@ -55,6 +55,7 @@ interface LineItemRowProps {
|
|||||||
suggestions: LineItemSuggestion[];
|
suggestions: LineItemSuggestion[];
|
||||||
onSelectSuggestion: (index: number, suggestion: LineItemSuggestion) => void;
|
onSelectSuggestion: (index: number, suggestion: LineItemSuggestion) => void;
|
||||||
onDescriptionChange: (index: number, value: string) => void;
|
onDescriptionChange: (index: number, value: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DescriptionAutocompleteProps {
|
interface DescriptionAutocompleteProps {
|
||||||
@@ -64,6 +65,7 @@ interface DescriptionAutocompleteProps {
|
|||||||
suggestions: LineItemSuggestion[];
|
suggestions: LineItemSuggestion[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DescriptionAutocomplete({
|
function DescriptionAutocomplete({
|
||||||
@@ -73,6 +75,7 @@ function DescriptionAutocomplete({
|
|||||||
suggestions,
|
suggestions,
|
||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
|
disabled,
|
||||||
}: DescriptionAutocompleteProps) {
|
}: DescriptionAutocompleteProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [activeIndex, setActiveIndex] = useState(-1);
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
@@ -116,6 +119,7 @@ function DescriptionAutocomplete({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={className}
|
className={className}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
{showDropdown && (
|
{showDropdown && (
|
||||||
<div className="bg-popover text-popover-foreground border-border absolute top-full left-0 z-50 mt-1 w-full overflow-hidden rounded-md border shadow-md">
|
<div className="bg-popover text-popover-foreground border-border absolute top-full left-0 z-50 mt-1 w-full overflow-hidden rounded-md border shadow-md">
|
||||||
@@ -146,12 +150,12 @@ function DescriptionAutocomplete({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||||
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange }, ref) => {
|
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -159,7 +163,8 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
inputClassName="h-9"
|
inputClassName="h-8 text-xs"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DescriptionAutocomplete
|
<DescriptionAutocomplete
|
||||||
@@ -167,8 +172,9 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
onChange={(v) => onDescriptionChange(index, v)}
|
onChange={(v) => onDescriptionChange(index, v)}
|
||||||
onSelect={(s) => onSelectSuggestion(index, s)}
|
onSelect={(s) => onSelectSuggestion(index, s)}
|
||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
placeholder="Describe the work performed..."
|
placeholder="Description"
|
||||||
className="h-9 w-full text-sm font-medium"
|
className="h-8 w-full text-sm"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -177,8 +183,9 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
min={0}
|
min={0}
|
||||||
step={0.25}
|
step={0.25}
|
||||||
width="full"
|
width="full"
|
||||||
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12"
|
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
|
||||||
suffix="h"
|
suffix="h"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -188,24 +195,29 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
step={1}
|
step={1}
|
||||||
prefix="$"
|
prefix="$"
|
||||||
width="full"
|
width="full"
|
||||||
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-14"
|
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-12 [&_input]:text-xs"
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="text-primary text-right font-mono font-semibold">
|
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
|
||||||
${(item.hours * item.rate).toFixed(2)}
|
${(item.hours * item.rate).toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
{!readOnly ? (
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => onRemove(index)}
|
size="sm"
|
||||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
onClick={() => onRemove(index)}
|
||||||
disabled={!canRemove}
|
className="text-muted-foreground hover:text-destructive h-7 w-7 p-0"
|
||||||
aria-label="Remove item"
|
disabled={!canRemove}
|
||||||
>
|
aria-label="Remove item"
|
||||||
<Trash2 className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -221,99 +233,75 @@ function MobileLineItem({
|
|||||||
suggestions,
|
suggestions,
|
||||||
onSelectSuggestion,
|
onSelectSuggestion,
|
||||||
onDescriptionChange,
|
onDescriptionChange,
|
||||||
|
readOnly,
|
||||||
}: LineItemRowProps) {
|
}: LineItemRowProps) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div
|
||||||
layout
|
|
||||||
id={`invoice-item-${index}-mobile`}
|
id={`invoice-item-${index}-mobile`}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
className="border-border space-y-1.5 border-b px-3 py-2 md:hidden"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -20 }}
|
|
||||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
|
||||||
className="border-border bg-card overflow-hidden rounded-lg border md:hidden"
|
|
||||||
>
|
>
|
||||||
<div className="space-y-3 p-4">
|
<div className="flex items-center gap-2">
|
||||||
{/* Description */}
|
<span className="text-muted-foreground w-5 shrink-0 text-center text-xs font-semibold">
|
||||||
<div className="space-y-1">
|
{index + 1}
|
||||||
<Label className="text-muted-foreground text-xs">Description</Label>
|
</span>
|
||||||
<DescriptionAutocomplete
|
<DescriptionAutocomplete
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(v) => onDescriptionChange(index, v)}
|
onChange={(v) => onDescriptionChange(index, v)}
|
||||||
onSelect={(s) => onSelectSuggestion(index, s)}
|
onSelect={(s) => onSelectSuggestion(index, s)}
|
||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
placeholder="Describe the work performed..."
|
placeholder="Description"
|
||||||
className="pl-3 text-sm"
|
className="h-8 flex-1 text-sm"
|
||||||
/>
|
disabled={readOnly}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Date */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-muted-foreground text-xs">Date</Label>
|
|
||||||
<DatePicker
|
|
||||||
date={item.date}
|
|
||||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
|
||||||
size="sm"
|
|
||||||
inputClassName="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hours and Rate in a row */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-muted-foreground text-xs">Hours</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={item.hours}
|
|
||||||
onChange={(value) => onUpdate(index, "hours", value)}
|
|
||||||
min={0}
|
|
||||||
step={0.25}
|
|
||||||
width="full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-muted-foreground text-xs">Rate</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={item.rate}
|
|
||||||
onChange={(value) => onUpdate(index, "rate", value)}
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
prefix="$"
|
|
||||||
width="full"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom section with controls, item name, and total */}
|
<div className="flex items-center gap-1.5 pl-7">
|
||||||
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
|
<DatePicker
|
||||||
<div className="flex items-center gap-2">
|
date={item.date}
|
||||||
|
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||||
|
size="sm"
|
||||||
|
className="w-[92px] shrink-0"
|
||||||
|
inputClassName="h-8 px-2 text-xs"
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
value={item.hours}
|
||||||
|
onChange={(value) => onUpdate(index, "hours", value)}
|
||||||
|
min={0}
|
||||||
|
step={0.25}
|
||||||
|
width="full"
|
||||||
|
className="h-8 w-[88px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-8 [&_input]:text-xs"
|
||||||
|
suffix="h"
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
value={item.rate}
|
||||||
|
onChange={(value) => onUpdate(index, "rate", value)}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
prefix="$"
|
||||||
|
width="full"
|
||||||
|
className="h-8 w-[84px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
|
||||||
|
${(item.hours * item.rate).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
{!readOnly ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onRemove(index)}
|
onClick={() => onRemove(index)}
|
||||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0 p-0"
|
||||||
disabled={!canRemove}
|
disabled={!canRemove}
|
||||||
aria-label="Remove item"
|
aria-label="Remove item"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
) : null}
|
||||||
<div className="flex-1 px-3 text-center">
|
|
||||||
<span className="text-muted-foreground block text-sm font-medium">
|
|
||||||
<span className="hidden sm:inline">Item </span>
|
|
||||||
<span className="sm:hidden">#</span>
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end">
|
|
||||||
<span className="text-muted-foreground text-xs">Total</span>
|
|
||||||
<span className="text-primary text-lg font-bold">
|
|
||||||
${(item.hours * item.rate).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +340,7 @@ export function InvoiceLineItems({
|
|||||||
clientId,
|
clientId,
|
||||||
defaultRate: _defaultRate,
|
defaultRate: _defaultRate,
|
||||||
className,
|
className,
|
||||||
|
readOnly = false,
|
||||||
}: InvoiceLineItemsProps) {
|
}: InvoiceLineItemsProps) {
|
||||||
const canRemoveItems = items.length > 1;
|
const canRemoveItems = items.length > 1;
|
||||||
const { search } = useLineItemSuggestions();
|
const { search } = useLineItemSuggestions();
|
||||||
@@ -378,13 +367,18 @@ export function InvoiceLineItems({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-2", className)}>
|
<div className={cn("space-y-2", className)}>
|
||||||
|
{readOnly ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Line items are locked after an invoice is sent. Revert to draft to edit entries.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<div className="space-y-2 md:space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
<div className="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">
|
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid">
|
||||||
<span>Date</span>
|
<span>Date</span>
|
||||||
<span>Description</span>
|
<span>Description</span>
|
||||||
<span className="text-right">Hours</span>
|
<span className="text-center">Hours</span>
|
||||||
<span className="text-right">Rate</span>
|
<span className="text-center">Rate</span>
|
||||||
<span className="text-right">Amount</span>
|
<span className="text-right">Amount</span>
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
@@ -408,6 +402,7 @@ export function InvoiceLineItems({
|
|||||||
suggestions={getSuggestionsForIndex(index)}
|
suggestions={getSuggestionsForIndex(index)}
|
||||||
onSelectSuggestion={handleSelectSuggestion}
|
onSelectSuggestion={handleSelectSuggestion}
|
||||||
onDescriptionChange={handleDescriptionChange}
|
onDescriptionChange={handleDescriptionChange}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -421,6 +416,7 @@ export function InvoiceLineItems({
|
|||||||
suggestions={getSuggestionsForIndex(index)}
|
suggestions={getSuggestionsForIndex(index)}
|
||||||
onSelectSuggestion={handleSelectSuggestion}
|
onSelectSuggestion={handleSelectSuggestion}
|
||||||
onDescriptionChange={handleDescriptionChange}
|
onDescriptionChange={handleDescriptionChange}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
@@ -444,22 +440,23 @@ export function InvoiceLineItems({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onAddItemWithValues && (
|
{onAddItemWithValues && !readOnly ? (
|
||||||
<NLQuickAdd onAdd={onAddItemWithValues} />
|
<NLQuickAdd onAdd={onAddItemWithValues} />
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Add Item Button */}
|
{!readOnly ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onAddItem}
|
onClick={onAddItem}
|
||||||
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"
|
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 mt-2 w-full border-dashed py-3 transition-all"
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Line Item
|
Add Line Item
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FileText, Loader2 } from "lucide-react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
export type InvoicePdfPreviewInput = {
|
||||||
|
invoiceNumber: string;
|
||||||
|
invoicePrefix: string;
|
||||||
|
businessId: string;
|
||||||
|
clientId: string;
|
||||||
|
issueDate: Date;
|
||||||
|
dueDate: Date;
|
||||||
|
status: "draft" | "sent" | "paid";
|
||||||
|
notes: string;
|
||||||
|
emailMessage: string;
|
||||||
|
taxRate: number;
|
||||||
|
currency: string;
|
||||||
|
items: Array<{
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
hours: number;
|
||||||
|
rate: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function canPreview(input: InvoicePdfPreviewInput | null): input is InvoicePdfPreviewInput {
|
||||||
|
if (!input?.clientId) return false;
|
||||||
|
if (input.items.length === 0) return false;
|
||||||
|
return input.items.every((item) => item.description.trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
type InvoicePdfPreviewPanelProps = {
|
||||||
|
input: InvoicePdfPreviewInput | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
heightClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvoicePdfPreviewPanel({
|
||||||
|
input,
|
||||||
|
enabled = true,
|
||||||
|
className,
|
||||||
|
heightClassName = "h-[min(80vh,760px)]",
|
||||||
|
}: InvoicePdfPreviewPanelProps) {
|
||||||
|
const previewReady = canPreview(input);
|
||||||
|
|
||||||
|
const { data: pdfPreview, isFetching, error, refetch } =
|
||||||
|
api.invoices.previewPdf.useQuery(input!, {
|
||||||
|
enabled: enabled && previewReady,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn("overflow-hidden", className)}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
PDF preview
|
||||||
|
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/20 overflow-hidden border-t",
|
||||||
|
heightClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!previewReady ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||||
|
Select a client and add descriptions for all line items to generate the
|
||||||
|
PDF preview.
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<p className="text-destructive text-sm">{error.message}</p>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => void refetch()}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : isFetching && !pdfPreview ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 p-6 text-center text-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Generating preview…
|
||||||
|
</div>
|
||||||
|
) : pdfPreview ? (
|
||||||
|
<iframe
|
||||||
|
title="Invoice 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { authClient } from "~/lib/auth-client";
|
|||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||||
import { navigationConfig } from "~/lib/navigation";
|
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
|
||||||
import { useSidebar } from "./sidebar-provider";
|
import { useSidebar } from "./sidebar-provider";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Logo } from "~/components/branding/logo";
|
import { Logo } from "~/components/branding/logo";
|
||||||
@@ -83,7 +83,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{section.links.map((link) => {
|
{section.links.map((link) => {
|
||||||
const Icon = link.icon;
|
const Icon = link.icon;
|
||||||
const isActive = pathname === link.href;
|
const isActive = isNavLinkActive(pathname, link.href);
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
export type LegalSection = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Body copy for legal pages — explicit styles (no typography plugin). */
|
||||||
|
export function LegalParagraph({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground text-[15px] leading-7",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LegalSectionBody({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 [&_a]:text-foreground [&_a]:font-medium [&_a]:underline [&_a]:underline-offset-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LegalTableOfContents({ sections }: { sections: LegalSection[] }) {
|
||||||
|
return (
|
||||||
|
<nav aria-label="Table of contents" className="text-sm">
|
||||||
|
<p className="text-foreground mb-3 font-medium">On this page</p>
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<li key={section.id}>
|
||||||
|
<Link
|
||||||
|
href={`#${section.id}`}
|
||||||
|
className="text-muted-foreground hover:text-foreground block leading-snug transition-colors"
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegalSectionBlock({
|
||||||
|
section,
|
||||||
|
isLast,
|
||||||
|
}: {
|
||||||
|
section: LegalSection;
|
||||||
|
isLast: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id={section.id}
|
||||||
|
className={cn("scroll-mt-24", !isLast && "border-border border-b")}
|
||||||
|
>
|
||||||
|
<div className="px-6 pt-8 pb-4 sm:px-8">
|
||||||
|
<h2 className="text-foreground text-lg font-semibold tracking-tight sm:text-xl">
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-10 sm:px-8">
|
||||||
|
<LegalSectionBody>{section.children}</LegalSectionBody>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LegalDocument({ sections }: { sections: LegalSection[] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-8 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)] lg:items-start">
|
||||||
|
<aside className="bg-card border-border rounded-lg border p-4 lg:sticky lg:top-8">
|
||||||
|
<LegalTableOfContents sections={sections} />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<article className="bg-card border-border overflow-hidden rounded-lg border shadow-sm">
|
||||||
|
{sections.map((section, index) => (
|
||||||
|
<LegalSectionBlock
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
isLast={index === sections.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
type LegalLinksProps = {
|
||||||
|
className?: string;
|
||||||
|
linkClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LegalLinks({ className, linkClassName }: LegalLinksProps) {
|
||||||
|
const linkStyles = cn(
|
||||||
|
"text-foreground font-medium hover:underline",
|
||||||
|
linkClassName,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={className}>
|
||||||
|
<Link href="/terms" className={linkStyles}>
|
||||||
|
Terms of Service
|
||||||
|
</Link>
|
||||||
|
{" and "}
|
||||||
|
<Link href="/privacy" className={linkStyles}>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LegalAgreementNoticeProps = {
|
||||||
|
action: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LegalAgreementNotice({
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
}: LegalAgreementNoticeProps) {
|
||||||
|
return (
|
||||||
|
<p className={cn("text-muted-foreground text-center text-xs", className)}>
|
||||||
|
By {action}, you agree to our <LegalLinks />.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Logo } from "~/components/branding/logo";
|
||||||
|
import { LEGAL_LAST_UPDATED } from "~/lib/legal";
|
||||||
|
|
||||||
|
type LegalPageShellProps = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LegalPageShell({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: LegalPageShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background min-h-screen">
|
||||||
|
<header className="border-border bg-card/80 border-b backdrop-blur-sm">
|
||||||
|
<div className="container mx-auto flex max-w-6xl flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<Logo size="sm" />
|
||||||
|
<Link href="/">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to app
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-3xl space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">{title}</h1>
|
||||||
|
{description ? (
|
||||||
|
<p className="text-muted-foreground text-base leading-relaxed">{description}</p>
|
||||||
|
) : null}
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Last updated {LEGAL_LAST_UPDATED}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import {
|
||||||
|
LEGAL_PRIVACY_EMAIL,
|
||||||
|
LEGAL_WEBSITE,
|
||||||
|
} from "~/lib/legal";
|
||||||
|
import { brand } from "~/lib/branding";
|
||||||
|
import {
|
||||||
|
LegalDocument,
|
||||||
|
LegalParagraph,
|
||||||
|
type LegalSection,
|
||||||
|
} from "~/components/legal/legal-document";
|
||||||
|
|
||||||
|
const sections: LegalSection[] = [
|
||||||
|
{
|
||||||
|
id: "introduction",
|
||||||
|
title: "Introduction",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
This Privacy Policy explains how {brand.name} collects, uses, and protects
|
||||||
|
information when you use our invoicing platform, including the web app and mobile
|
||||||
|
app (the “Service”).
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
If you have questions about this policy, email us at{" "}
|
||||||
|
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "information-we-collect",
|
||||||
|
title: "Information we collect",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
When you create an account and use the Service, you provide information such as
|
||||||
|
your name, email address, business details, client records, invoice content, and
|
||||||
|
time entries. This is the data you enter to run your invoicing workflow.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
You may also add payment instructions that appear on invoices, such as bank
|
||||||
|
transfer details. We do not process card payments on your behalf.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
We also collect some technical information automatically so the Service stays
|
||||||
|
secure and reliable. This can include your IP address, device and browser or app
|
||||||
|
details, log and diagnostic data, and session cookies that keep you signed in.
|
||||||
|
Some deployments may use optional, privacy-focused analytics.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "how-we-use-information",
|
||||||
|
title: "How we use information",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
We use your information to provide and operate the Service, authenticate your
|
||||||
|
account, send transactional messages such as password resets, respond to support
|
||||||
|
requests, monitor security and performance, and meet legal obligations.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "how-we-share",
|
||||||
|
title: "How we share information",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
We do not sell your personal information. We share it only when needed to run the
|
||||||
|
Service or when the law requires it.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
We work with service providers that host our infrastructure, deliver transactional
|
||||||
|
email, support single sign-on when enabled on your instance, and optionally provide
|
||||||
|
privacy-focused analytics. These vendors may process your information only to
|
||||||
|
perform services for us.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
We may disclose information if we believe it is reasonably necessary to comply with
|
||||||
|
law, respond to a valid legal request, or protect the security and integrity of the
|
||||||
|
Service.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
If we are involved in a merger, acquisition, or sale of assets, your information
|
||||||
|
may be transferred as part of that transaction, subject to continued protection
|
||||||
|
consistent with this policy.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "security-retention",
|
||||||
|
title: "Security and retention",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
We use reasonable safeguards to protect information, including encryption in
|
||||||
|
transit, access controls, and secure authentication. No method of transmission or
|
||||||
|
storage is completely secure.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
We retain information for as long as you have an account or as needed to provide
|
||||||
|
the Service. We may keep certain records longer when required by law or for
|
||||||
|
legitimate purposes such as fraud prevention or dispute resolution.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "your-rights",
|
||||||
|
title: "Your rights",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
Depending on where you live, you may have the right to access, correct, delete, or
|
||||||
|
export your personal information, or to object to or restrict certain processing.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
To exercise these rights, contact us at{" "}
|
||||||
|
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>. We will
|
||||||
|
respond within a reasonable timeframe and as required by applicable law.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cookies",
|
||||||
|
title: "Cookies",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
We use cookies and similar technologies to keep you signed in, remember
|
||||||
|
preferences such as theme, and, when enabled on a deployment, measure usage with
|
||||||
|
privacy-focused analytics.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
You can control cookies through your browser settings. If you disable essential
|
||||||
|
cookies, some parts of the Service may not work correctly.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "other",
|
||||||
|
title: "Other disclosures",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
The Service may link to third-party websites or integrate with services you
|
||||||
|
configure, such as single sign-on. Those services have their own privacy policies,
|
||||||
|
and we are not responsible for their practices.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
The Service is not intended for children under 13. If you believe a child has
|
||||||
|
provided us personal information, contact us and we will delete it.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
Your information may be processed in countries other than your own. Where required,
|
||||||
|
we use appropriate safeguards for international transfers.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
We may update this policy from time to time. If we make material changes, we will
|
||||||
|
post the updated policy on the Service and may notify you by email. Continued use
|
||||||
|
after changes take effect means you accept the updated policy.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contact",
|
||||||
|
title: "Contact",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
For privacy questions or requests, email{" "}
|
||||||
|
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a> or visit{" "}
|
||||||
|
<a href={LEGAL_WEBSITE} target="_blank" rel="noopener noreferrer">
|
||||||
|
{LEGAL_WEBSITE.replace(/^https?:\/\//, "")}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PrivacyPolicyContent() {
|
||||||
|
return <LegalDocument sections={sections} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import {
|
||||||
|
LEGAL_PRIVACY_EMAIL,
|
||||||
|
LEGAL_TERMS_EMAIL,
|
||||||
|
LEGAL_WEBSITE,
|
||||||
|
} from "~/lib/legal";
|
||||||
|
import { brand } from "~/lib/branding";
|
||||||
|
import {
|
||||||
|
LegalDocument,
|
||||||
|
LegalParagraph,
|
||||||
|
type LegalSection,
|
||||||
|
} from "~/components/legal/legal-document";
|
||||||
|
|
||||||
|
const sections: LegalSection[] = [
|
||||||
|
{
|
||||||
|
id: "agreement",
|
||||||
|
title: "Agreement to these terms",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
These Terms of Service (“Terms”) govern your use of the {brand.name}{" "}
|
||||||
|
platform, including the web app and mobile app (the “Service”).
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
By accessing or using the Service, you agree to these Terms. If you do
|
||||||
|
not agree, do not use the Service.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "service",
|
||||||
|
title: "The Service",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
{brand.name} helps you create and manage invoices, track clients and
|
||||||
|
businesses, record billable time, and review basic financial summaries.
|
||||||
|
You may use the official hosted Service or connect the mobile app to a
|
||||||
|
self-hosted {brand.name} server you control.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
The Service is a tool for your business records. We do not provide
|
||||||
|
legal, tax, or accounting advice, and you are responsible for the
|
||||||
|
accuracy of invoices and compliance with laws that apply to you.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accounts",
|
||||||
|
title: "Accounts and security",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
When you create an account, you agree to provide accurate information
|
||||||
|
and keep it up to date. You are responsible for activity under your
|
||||||
|
account and for keeping your credentials secure.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
Notify us promptly if you suspect unauthorized access to your account.
|
||||||
|
We may suspend or restrict access if we believe your account is
|
||||||
|
compromised or used in violation of these Terms.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "acceptable-use",
|
||||||
|
title: "Acceptable use",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
You agree to use the Service lawfully and only for its intended
|
||||||
|
purpose. You may not use the Service to break the law, infringe
|
||||||
|
others’ rights, transmit malware, attempt to gain unauthorized access,
|
||||||
|
interfere with the Service’s operation, or harass or harm others.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
You may not use the Service to send spam, publish false or misleading
|
||||||
|
information, or scrape or overload our systems without permission.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "data-privacy",
|
||||||
|
title: "Your data and privacy",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
Your privacy matters to us. Our{" "}
|
||||||
|
<Link href="/privacy">Privacy Policy</Link> explains how we collect
|
||||||
|
and use information and is incorporated into these Terms.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
You retain ownership of the content you enter into the Service, such as
|
||||||
|
clients, invoices, and time entries. We do not sell your personal
|
||||||
|
information. We may process your data as described in the Privacy
|
||||||
|
Policy to provide and secure the Service.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
You are responsible for maintaining your own backups of important
|
||||||
|
business records. While we take reasonable steps to protect data, you
|
||||||
|
should not rely on the Service as your only copy of critical
|
||||||
|
information.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fees",
|
||||||
|
title: "Fees",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
Access to the Service may be offered without charge today, but we
|
||||||
|
reserve the right to introduce fees for certain features or hosted
|
||||||
|
plans in the future. If we do, we will provide reasonable notice
|
||||||
|
before any new fees apply to you.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
The mobile app does not offer in-app purchases. If you run a
|
||||||
|
self-hosted instance, you are responsible for the costs and
|
||||||
|
administration of that environment.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "intellectual-property",
|
||||||
|
title: "Intellectual property",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
The Service, including its software, design, and branding, is owned by{" "}
|
||||||
|
{brand.name} and its licensors and is protected by applicable
|
||||||
|
intellectual property laws.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
You may not copy, modify, or reverse engineer the Service except where
|
||||||
|
the law expressly allows. Our name and marks may not be used without
|
||||||
|
our prior written permission.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "termination",
|
||||||
|
title: "Termination",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
You may stop using the Service at any time. You may also contact us to
|
||||||
|
request account deletion.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
We may suspend or terminate your access if you violate these Terms, if
|
||||||
|
required by law, or if we discontinue the Service. Upon termination,
|
||||||
|
your right to use the Service ends immediately, subject to any legal
|
||||||
|
obligations that require us to retain certain data.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "disclaimers",
|
||||||
|
title: "Disclaimers and limitation of liability",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
The Service is provided “as is” and “as available.” To the fullest
|
||||||
|
extent permitted by law, we disclaim warranties of merchantability,
|
||||||
|
fitness for a particular purpose, and non-infringement. We do not
|
||||||
|
guarantee that the Service will be uninterrupted or error-free.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
To the fullest extent permitted by law, {brand.name} and its
|
||||||
|
affiliates will not be liable for indirect, incidental, special,
|
||||||
|
consequential, or punitive damages, or for loss of profits, data, or
|
||||||
|
goodwill, arising from your use of the Service.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
Nothing in these Terms limits liability that cannot be limited under
|
||||||
|
applicable law, including liability for fraud or for death or personal
|
||||||
|
injury caused by negligence.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "general",
|
||||||
|
title: "General",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
These Terms are governed by the laws applicable where {brand.name}{" "}
|
||||||
|
operates, without regard to conflict-of-law rules. If we do not
|
||||||
|
enforce a provision, that does not waive our right to enforce it
|
||||||
|
later.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
We may update these Terms from time to time. If we make material
|
||||||
|
changes, we will post the updated Terms on the Service and may notify
|
||||||
|
you by email. Continued use after changes take effect means you accept
|
||||||
|
the revised Terms.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contact",
|
||||||
|
title: "Contact",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<LegalParagraph>
|
||||||
|
Questions about these Terms:{" "}
|
||||||
|
<a href={`mailto:${LEGAL_TERMS_EMAIL}`}>{LEGAL_TERMS_EMAIL}</a>.
|
||||||
|
Privacy questions:{" "}
|
||||||
|
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>.
|
||||||
|
Website:{" "}
|
||||||
|
<a href={LEGAL_WEBSITE} target="_blank" rel="noopener noreferrer">
|
||||||
|
{LEGAL_WEBSITE.replace(/^https?:\/\//, "")}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</LegalParagraph>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TermsOfServiceContent() {
|
||||||
|
return <LegalDocument sections={sections} />;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { usePathname } from "next/navigation";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { useAuthSession } from "~/hooks/use-auth-session";
|
import { useAuthSession } from "~/hooks/use-auth-session";
|
||||||
import { navigationConfig } from "~/lib/navigation";
|
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
|
||||||
|
|
||||||
interface SidebarTriggerProps {
|
interface SidebarTriggerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -68,10 +68,10 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
|
|||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
aria-current={
|
aria-current={
|
||||||
pathname === link.href ? "page" : undefined
|
isNavLinkActive(pathname, link.href) ? "page" : undefined
|
||||||
}
|
}
|
||||||
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${
|
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||||
pathname === link.href
|
isNavLinkActive(pathname, link.href)
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-foreground hover:bg-muted"
|
: "text-foreground hover:bg-muted"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -15,9 +15,29 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { Clock, Play, Square, ExternalLink } from "lucide-react";
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "~/components/ui/collapsible";
|
||||||
|
import { ChevronDown, Clock, ExternalLink, Play, Square } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
|
import { cn } from "~/lib/utils";
|
||||||
|
import {
|
||||||
|
getLastTimeClockClientId,
|
||||||
|
setLastTimeClockClientId,
|
||||||
|
} from "~/lib/time-clock-prefs";
|
||||||
|
import {
|
||||||
|
describeClockOutOutcome,
|
||||||
|
formatElapsedSeconds,
|
||||||
|
resolveClockDescription,
|
||||||
|
resolveEffectiveHourlyRate,
|
||||||
|
startedAtFromMinutesAgo,
|
||||||
|
} from "~/lib/time-clock";
|
||||||
|
|
||||||
|
const FEATURED_CLIENT_COUNT = 4;
|
||||||
|
|
||||||
|
type StartMode = "now" | "pick" | "ago";
|
||||||
|
|
||||||
export type TimeClockPanelProps = {
|
export type TimeClockPanelProps = {
|
||||||
defaultClientId?: string;
|
defaultClientId?: string;
|
||||||
@@ -25,6 +45,38 @@ export type TimeClockPanelProps = {
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function invoiceLabel(inv: {
|
||||||
|
invoicePrefix: string | null;
|
||||||
|
invoiceNumber: string;
|
||||||
|
}) {
|
||||||
|
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientChip({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-background hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TimeClockPanel({
|
export function TimeClockPanel({
|
||||||
defaultClientId = "",
|
defaultClientId = "",
|
||||||
defaultInvoiceId = "",
|
defaultInvoiceId = "",
|
||||||
@@ -37,19 +89,6 @@ export function TimeClockPanel({
|
|||||||
);
|
);
|
||||||
const { data: clients } = api.clients.getAll.useQuery();
|
const { data: clients } = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
const [clientId, setClientId] = useState(defaultClientId);
|
|
||||||
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [rate, setRate] = useState(0);
|
|
||||||
const [elapsed, setElapsed] = useState(0);
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
const draftClientId = running ? (running.clientId ?? "") : clientId;
|
|
||||||
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
|
|
||||||
draftClientId ? { clientId: draftClientId } : undefined,
|
|
||||||
{ enabled: Boolean(draftClientId) },
|
|
||||||
);
|
|
||||||
|
|
||||||
const todayStart = useMemo(() => {
|
const todayStart = useMemo(() => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setHours(0, 0, 0, 0);
|
d.setHours(0, 0, 0, 0);
|
||||||
@@ -60,6 +99,63 @@ export function TimeClockPanel({
|
|||||||
from: todayStart,
|
from: todayStart,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [clientId, setClientId] = useState(() => {
|
||||||
|
if (defaultClientId) return defaultClientId;
|
||||||
|
return getLastTimeClockClientId() ?? "";
|
||||||
|
});
|
||||||
|
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [stopNote, setStopNote] = useState("");
|
||||||
|
const [rate, setRate] = useState(0);
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const [showAllClients, setShowAllClients] = useState(false);
|
||||||
|
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||||
|
const [startMode, setStartMode] = useState<StartMode>("now");
|
||||||
|
const [pickedStart, setPickedStart] = useState("");
|
||||||
|
const [minutesAgo, setMinutesAgo] = useState("30");
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const draftClientId = running ? (running.clientId ?? "") : clientId;
|
||||||
|
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
|
||||||
|
draftClientId ? { clientId: draftClientId } : undefined,
|
||||||
|
{ enabled: Boolean(draftClientId) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedClient = useMemo(
|
||||||
|
() => clients?.find((c) => c.id === clientId),
|
||||||
|
[clients, clientId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const featuredClientIds = useMemo(() => {
|
||||||
|
const ids: string[] = [];
|
||||||
|
const last = getLastTimeClockClientId();
|
||||||
|
if (last) ids.push(last);
|
||||||
|
|
||||||
|
for (const entry of todayEntries ?? []) {
|
||||||
|
if (entry.clientId && !ids.includes(entry.clientId)) {
|
||||||
|
ids.push(entry.clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const client of clients ?? []) {
|
||||||
|
if (!ids.includes(client.id)) ids.push(client.id);
|
||||||
|
if (ids.length >= FEATURED_CLIENT_COUNT) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}, [clients, todayEntries]);
|
||||||
|
|
||||||
|
const visibleClients = useMemo(() => {
|
||||||
|
if (!clients?.length) return [];
|
||||||
|
if (showAllClients) return clients;
|
||||||
|
const featured = featuredClientIds
|
||||||
|
.map((id) => clients.find((c) => c.id === id))
|
||||||
|
.filter((c): c is NonNullable<typeof c> => Boolean(c));
|
||||||
|
return featured.length > 0 ? featured : clients.slice(0, FEATURED_CLIENT_COUNT);
|
||||||
|
}, [clients, featuredClientIds, showAllClients]);
|
||||||
|
|
||||||
|
const hiddenClientCount = Math.max(0, (clients?.length ?? 0) - visibleClients.length);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
@@ -110,7 +206,8 @@ export function TimeClockPanel({
|
|||||||
void utils.invoices.getAll.invalidate();
|
void utils.invoices.getAll.invalidate();
|
||||||
void utils.invoices.getBillable.invalidate();
|
void utils.invoices.getBillable.invalidate();
|
||||||
void utils.dashboard.getStats.invalidate();
|
void utils.dashboard.getStats.invalidate();
|
||||||
setDescription("");
|
setTitle("");
|
||||||
|
setStopNote("");
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
@@ -118,10 +215,60 @@ export function TimeClockPanel({
|
|||||||
function handleClientChange(value: string) {
|
function handleClientChange(value: string) {
|
||||||
setClientId(value);
|
setClientId(value);
|
||||||
setInvoiceId("");
|
setInvoiceId("");
|
||||||
|
setLastTimeClockClientId(value);
|
||||||
const client = clients?.find((c) => c.id === value);
|
const client = clients?.find((c) => c.id === value);
|
||||||
setRate(client?.defaultHourlyRate ?? 0);
|
setRate(client?.defaultHourlyRate ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveStartedAt(): Date | undefined {
|
||||||
|
if (startMode === "now") return undefined;
|
||||||
|
if (startMode === "pick") {
|
||||||
|
if (!pickedStart) {
|
||||||
|
toast.error("Choose a start date and time");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = new Date(pickedStart);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
toast.error("Invalid start time");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
const minutes = Number(minutesAgo);
|
||||||
|
if (!Number.isFinite(minutes) || minutes < 1 || minutes > 24 * 60) {
|
||||||
|
toast.error("Enter minutes between 1 and 1440");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return startedAtFromMinutesAgo(minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStartMode(mode: StartMode) {
|
||||||
|
setStartMode(mode);
|
||||||
|
if (mode === "pick" && !pickedStart) {
|
||||||
|
const now = new Date();
|
||||||
|
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||||
|
setPickedStart(now.toISOString().slice(0, 16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStart() {
|
||||||
|
const startedAt = resolveStartedAt();
|
||||||
|
if (startMode !== "now" && !startedAt) return;
|
||||||
|
|
||||||
|
const description = resolveClockDescription(title);
|
||||||
|
const effectiveRate = resolveEffectiveHourlyRate(rate, selectedClient);
|
||||||
|
|
||||||
|
if (clientId) setLastTimeClockClientId(clientId);
|
||||||
|
|
||||||
|
clockIn.mutate({
|
||||||
|
description,
|
||||||
|
clientId: clientId || "",
|
||||||
|
invoiceId: invoiceId || undefined,
|
||||||
|
rate: effectiveRate > 0 ? effectiveRate : undefined,
|
||||||
|
startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (runningLoading) {
|
if (runningLoading) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -130,61 +277,82 @@ export function TimeClockPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoiceLabel = (inv: {
|
|
||||||
invoicePrefix: string | null;
|
|
||||||
invoiceNumber: string;
|
|
||||||
status: string;
|
|
||||||
}) => `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.status})`;
|
|
||||||
|
|
||||||
const displayDescription = running ? running.description : description;
|
|
||||||
const displayRate = running ? (running.rate ?? 0) : rate;
|
const displayRate = running ? (running.rate ?? 0) : rate;
|
||||||
|
const runningTitle =
|
||||||
|
running?.description?.trim() ?? resolveClockDescription("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={compact ? "space-y-4" : "space-y-6"}>
|
<div className={compact ? "space-y-4" : "space-y-6"}>
|
||||||
<Card className={running ? "border-primary/30 bg-primary/5" : undefined}>
|
{running ? (
|
||||||
<CardHeader>
|
<div className="border-primary/20 bg-primary/5 rounded-2xl border p-6 text-center shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-center gap-2">
|
||||||
|
<span className="relative flex h-2.5 w-2.5">
|
||||||
|
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||||
|
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
|
||||||
|
</span>
|
||||||
|
<span className="text-primary text-sm font-medium">Timer running</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-primary font-mono text-5xl font-bold tracking-tight tabular-nums sm:text-6xl">
|
||||||
|
{formatElapsedSeconds(elapsed)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-lg font-medium">{runningTitle}</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
{running.client?.name ?? "No client"}
|
||||||
|
{running.invoice ? ` · ${invoiceLabel(running.invoice)}` : ""}
|
||||||
|
{displayRate ? ` · $${displayRate}/hr` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
{running ? (
|
{!running ? <Clock className="h-4 w-4" /> : null}
|
||||||
<span className="relative flex h-3 w-3">
|
{running ? "Update & stop" : "Clock in"}
|
||||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
|
||||||
<span className="bg-primary relative inline-flex h-3 w-3 rounded-full" />
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Clock className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{running ? "Timer running" : "Time clock"}
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-5">
|
||||||
{running ? (
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="min-w-0 space-y-1">
|
|
||||||
<p className="font-medium">
|
|
||||||
{displayDescription || (
|
|
||||||
<span className="text-muted-foreground italic">No description</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{running.client?.name ?? "No client"}
|
|
||||||
{running.invoice
|
|
||||||
? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
|
||||||
: ""}
|
|
||||||
{displayRate ? ` · $${displayRate}/hr` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-primary font-mono text-4xl font-bold tabular-nums">
|
|
||||||
{formatElapsedSeconds(elapsed)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!running ? (
|
{!running ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-1.5">
|
<Label htmlFor="clock-title" className="sr-only">
|
||||||
<Label>Client</Label>
|
What are you working on?
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="clock-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="What are you working on?"
|
||||||
|
className="h-12 border-0 bg-transparent px-0 text-lg font-medium shadow-none focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Client</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{visibleClients.map((client) => (
|
||||||
|
<ClientChip
|
||||||
|
key={client.id}
|
||||||
|
label={client.name}
|
||||||
|
active={clientId === client.id}
|
||||||
|
onClick={() => handleClientChange(client.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!showAllClients && hiddenClientCount > 0 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-full"
|
||||||
|
onClick={() => setShowAllClients(true)}
|
||||||
|
>
|
||||||
|
+{hiddenClientCount} more
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && (
|
||||||
<Select value={clientId || undefined} onValueChange={handleClientChange}>
|
<Select value={clientId || undefined} onValueChange={handleClientChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="mt-1">
|
||||||
<SelectValue placeholder="Select client" />
|
<SelectValue placeholder="Select client" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -195,66 +363,122 @@ export function TimeClockPanel({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label>Invoice</Label>
|
<Label>Invoice</Label>
|
||||||
<Select
|
<Select
|
||||||
value={invoiceId || "__none__"}
|
value={invoiceId || "__none__"}
|
||||||
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
|
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
|
||||||
disabled={!clientId}
|
disabled={!clientId}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
clientId ? "Draft invoice (optional)" : "Choose a client first"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">No invoice — save entry only</SelectItem>
|
||||||
|
{billableInvoices?.map((inv) => (
|
||||||
|
<SelectItem key={inv.id} value={inv.id}>
|
||||||
|
{invoiceLabel(inv)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Collapsible open={optionsOpen} onOpenChange={setOptionsOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground h-auto w-full justify-between px-0 py-1 font-normal hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
Rate & start time
|
||||||
<SelectValue
|
<ChevronDown
|
||||||
placeholder={
|
className={cn(
|
||||||
clientId ? "Select invoice (optional)" : "Choose a client first"
|
"h-4 w-4 shrink-0 transition-transform",
|
||||||
}
|
optionsOpen && "rotate-180",
|
||||||
/>
|
)}
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
</Button>
|
||||||
<SelectItem value="__none__">No invoice — save entry only</SelectItem>
|
</CollapsibleTrigger>
|
||||||
{billableInvoices?.map((inv) => (
|
<CollapsibleContent className="space-y-4 pt-2">
|
||||||
<SelectItem key={inv.id} value={inv.id}>
|
<div className="space-y-2">
|
||||||
{invoiceLabel(inv)}
|
<Label>Hourly rate</Label>
|
||||||
</SelectItem>
|
<NumberInput
|
||||||
|
value={rate}
|
||||||
|
onChange={setRate}
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
{clientId && rate === 0 && selectedClient?.defaultHourlyRate ? (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Client default: ${selectedClient.defaultHourlyRate}/hr (used when left at zero).
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>When to start</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["now", "Now"],
|
||||||
|
["pick", "Pick time"],
|
||||||
|
["ago", "Time ago"],
|
||||||
|
] as const
|
||||||
|
).map(([mode, label]) => (
|
||||||
|
<Button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={startMode === mode ? "default" : "outline"}
|
||||||
|
className="rounded-full"
|
||||||
|
onClick={() => selectStartMode(mode)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
{startMode === "pick" ? (
|
||||||
</div>
|
<Input
|
||||||
</div>
|
type="datetime-local"
|
||||||
|
value={pickedStart}
|
||||||
<div className="space-y-1.5">
|
onChange={(e) => setPickedStart(e.target.value)}
|
||||||
<Label>Description</Label>
|
className="mt-2"
|
||||||
<Input
|
/>
|
||||||
value={description}
|
) : null}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
{startMode === "ago" ? (
|
||||||
placeholder="What are you working on?"
|
<div className="mt-2 flex items-center gap-2">
|
||||||
/>
|
<Input
|
||||||
</div>
|
type="number"
|
||||||
|
min={1}
|
||||||
<div className="space-y-1.5">
|
max={1440}
|
||||||
<Label>Hourly rate</Label>
|
value={minutesAgo}
|
||||||
<NumberInput
|
onChange={(e) => setMinutesAgo(e.target.value)}
|
||||||
value={rate}
|
className="w-24"
|
||||||
onChange={setRate}
|
/>
|
||||||
min={0}
|
<span className="text-muted-foreground text-sm">minutes ago</span>
|
||||||
step={0.01}
|
</div>
|
||||||
placeholder="0.00"
|
) : null}
|
||||||
/>
|
</div>
|
||||||
{clientId && rate === 0 ? (
|
</CollapsibleContent>
|
||||||
<p className="text-muted-foreground text-xs">
|
</Collapsible>
|
||||||
Set a rate or add a default on the client record.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label>Update description on stop (optional)</Label>
|
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
value={description}
|
id="clock-stop-note"
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
value={stopNote}
|
||||||
placeholder={running.description || "What did you work on?"}
|
onChange={(e) => setStopNote(e.target.value)}
|
||||||
|
placeholder={running?.description || "Update description when you stop"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -262,8 +486,13 @@ export function TimeClockPanel({
|
|||||||
{running ? (
|
{running ? (
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => clockOut.mutate({ description: description || undefined })}
|
onClick={() =>
|
||||||
|
clockOut.mutate({
|
||||||
|
description: stopNote.trim() || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
disabled={clockOut.isPending}
|
disabled={clockOut.isPending}
|
||||||
>
|
>
|
||||||
<Square className="mr-2 h-4 w-4" />
|
<Square className="mr-2 h-4 w-4" />
|
||||||
@@ -271,15 +500,9 @@ export function TimeClockPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() =>
|
onClick={handleStart}
|
||||||
clockIn.mutate({
|
|
||||||
description,
|
|
||||||
clientId: clientId || "",
|
|
||||||
invoiceId: invoiceId || undefined,
|
|
||||||
rate: rate || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={clockIn.isPending}
|
disabled={clockIn.isPending}
|
||||||
>
|
>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -1,350 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "~/components/ui/dialog";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
|
|
||||||
interface LegalModalProps {
|
|
||||||
type: "terms" | "privacy";
|
|
||||||
trigger: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LegalModal({ type, trigger }: LegalModalProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const isTerms = type === "terms";
|
|
||||||
const title = isTerms ? "Terms of Service" : "Privacy Policy";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<span className="inline" onClick={() => setOpen(true)}>
|
|
||||||
{trigger}
|
|
||||||
</span>
|
|
||||||
<DialogContent className="max-h-[80vh] max-w-6xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center justify-between">
|
|
||||||
{title}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<ScrollArea className="h-full max-h-[60vh] pr-4">
|
|
||||||
{isTerms ? <TermsContent /> : <PrivacyContent />}
|
|
||||||
</ScrollArea>
|
|
||||||
<div className="flex justify-end pt-4">
|
|
||||||
<Button onClick={() => setOpen(false)}>Close</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TermsContent() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Agreement to Terms</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
These Terms of Service ("Terms") govern your use of the
|
|
||||||
beenvoice platform and services (the "Service") operated
|
|
||||||
by beenvoice ("us", "we", or "our").
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
By accessing or using our Service, you agree to be bound by these
|
|
||||||
Terms. If you disagree with any part of these terms, then you may
|
|
||||||
not access the Service.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Description of Service</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
beenvoice is a web-based invoicing platform that allows users to:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Create and manage professional invoices</li>
|
|
||||||
<li>Track client information and billing details</li>
|
|
||||||
<li>Monitor payment status and financial metrics</li>
|
|
||||||
<li>Generate reports and analytics</li>
|
|
||||||
<li>Manage business profiles and settings</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>User Accounts</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
When you create an account with us, you must provide information
|
|
||||||
that is accurate, complete, and current at all times. You are
|
|
||||||
responsible for safeguarding the password and for all activities
|
|
||||||
that occur under your account.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You agree not to disclose your password to any third party. You must
|
|
||||||
notify us immediately upon becoming aware of any breach of security
|
|
||||||
or unauthorized use of your account.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Acceptable Use</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>You agree not to use the Service:</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
For any unlawful purpose or to solicit others to perform unlawful
|
|
||||||
acts
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
To violate any international, federal, provincial, or state
|
|
||||||
regulations, rules, laws, or local ordinances
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
To infringe upon or violate our intellectual property rights or
|
|
||||||
the intellectual property rights of others
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
To harass, abuse, insult, harm, defame, slander, disparage,
|
|
||||||
intimidate, or discriminate
|
|
||||||
</li>
|
|
||||||
<li>To submit false or misleading information</li>
|
|
||||||
<li>
|
|
||||||
To upload or transmit viruses or any other type of malicious code
|
|
||||||
</li>
|
|
||||||
<li>To spam, phish, pharm, pretext, spider, crawl, or scrape</li>
|
|
||||||
<li>For any obscene or immoral purpose</li>
|
|
||||||
<li>
|
|
||||||
To interfere with or circumvent the security features of the
|
|
||||||
Service
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Payment Terms</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
Some aspects of the Service may require payment. You will be charged
|
|
||||||
according to your subscription plan. All fees are non-refundable
|
|
||||||
unless otherwise stated.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
We may change our fees at any time. We will provide you with
|
|
||||||
reasonable notice of any fee changes by posting the new fees on the
|
|
||||||
Service or sending you email notification.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Termination</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
We may terminate or suspend your account and bar access to the
|
|
||||||
Service immediately, without prior notice or liability, under our
|
|
||||||
sole discretion, for any reason whatsoever and without limitation,
|
|
||||||
including but not limited to a breach of the Terms.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If you wish to terminate your account, you may simply discontinue
|
|
||||||
using the Service and contact us to request account deletion.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Contact Information</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
If you have any questions about these Terms of Service, please
|
|
||||||
contact us at:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Email: legal@beenvoice.com</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PrivacyContent() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Information We Collect</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<h4>Personal Information</h4>
|
|
||||||
<p>
|
|
||||||
We may collect personal information that you voluntarily provide to
|
|
||||||
us when you:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Register for an account</li>
|
|
||||||
<li>Create invoices or manage client information</li>
|
|
||||||
<li>Contact us for support</li>
|
|
||||||
</ul>
|
|
||||||
<p>This personal information may include:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Name and contact information (email, phone, address)</li>
|
|
||||||
<li>Business information and tax details</li>
|
|
||||||
<li>Client information you input into the system</li>
|
|
||||||
<li>Financial information related to your invoices</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>How We Use Your Information</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>We use the information we collect to:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Provide, operate, and maintain our Service</li>
|
|
||||||
<li>Process your transactions and manage your account</li>
|
|
||||||
<li>Improve and personalize your experience</li>
|
|
||||||
<li>Communicate with you about your account and our services</li>
|
|
||||||
<li>Send you technical notices and support messages</li>
|
|
||||||
<li>Respond to your comments, questions, and requests</li>
|
|
||||||
<li>Monitor usage and analyze trends</li>
|
|
||||||
<li>
|
|
||||||
Detect, prevent, and address technical issues and security
|
|
||||||
breaches
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>How We Share Your Information</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
We do not sell, trade, or rent your personal information to third
|
|
||||||
parties. We may share your information in the following
|
|
||||||
circumstances:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Service Providers</h4>
|
|
||||||
<p>
|
|
||||||
We may share your information with trusted third-party service
|
|
||||||
providers who assist us in operating our Service, such as:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Cloud hosting and storage providers</li>
|
|
||||||
<li>Payment processors</li>
|
|
||||||
<li>Email service providers</li>
|
|
||||||
<li>Analytics and monitoring services</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>Legal Requirements</h4>
|
|
||||||
<p>
|
|
||||||
We may disclose your information if required to do so by law or in
|
|
||||||
response to:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Legal processes (subpoenas, court orders)</li>
|
|
||||||
<li>Government requests</li>
|
|
||||||
<li>Law enforcement investigations</li>
|
|
||||||
<li>Protection of our rights, property, or safety</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Data Security</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
We implement appropriate technical and organizational security
|
|
||||||
measures to protect your information:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Encryption of data in transit and at rest</li>
|
|
||||||
<li>Secure access controls and authentication</li>
|
|
||||||
<li>Regular security assessments and updates</li>
|
|
||||||
<li>Employee training on data protection</li>
|
|
||||||
<li>Incident response procedures</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Your Rights and Choices</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
Depending on your location, you may have the following rights
|
|
||||||
regarding your personal information:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Request access to your personal information</li>
|
|
||||||
<li>Correct inaccurate or incomplete information</li>
|
|
||||||
<li>Request deletion of your personal information</li>
|
|
||||||
<li>Restrict the processing of your information</li>
|
|
||||||
<li>Object to certain uses of your data</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
To exercise these rights, please contact us at
|
|
||||||
privacy@beenvoice.com.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Contact Us</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="prose prose-sm max-w-none">
|
|
||||||
<p>
|
|
||||||
If you have questions about this Privacy Policy or our privacy
|
|
||||||
practices, please contact us at:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>Email: privacy@beenvoice.com</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** Default invoice number format (matches web/mobile create forms). */
|
||||||
|
export function generateInvoiceNumber(now = new Date()): string {
|
||||||
|
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
|
||||||
|
return `INV-${date}-${String(now.getTime()).slice(-6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultDueDate(issueDate: Date): Date {
|
||||||
|
const due = new Date(issueDate);
|
||||||
|
due.setDate(due.getDate() + 30);
|
||||||
|
return due;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const LEGAL_LAST_UPDATED = "June 18, 2026";
|
||||||
|
|
||||||
|
export const LEGAL_PRIVACY_EMAIL = "privacy@soconnor.dev";
|
||||||
|
export const LEGAL_TERMS_EMAIL = "legal@soconnor.dev";
|
||||||
|
export const LEGAL_WEBSITE = "https://beenvoice.soconnor.dev";
|
||||||
+12
-3
@@ -3,7 +3,6 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
Building,
|
|
||||||
Receipt,
|
Receipt,
|
||||||
BarChart2,
|
BarChart2,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -22,14 +21,24 @@ export interface NavSection {
|
|||||||
links: NavLink[];
|
links: NavLink[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isNavLinkActive(pathname: string, href: string): boolean {
|
||||||
|
if (href === "/dashboard/entities") {
|
||||||
|
return (
|
||||||
|
pathname === href ||
|
||||||
|
pathname.startsWith("/dashboard/clients") ||
|
||||||
|
pathname.startsWith("/dashboard/businesses")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return pathname === href;
|
||||||
|
}
|
||||||
|
|
||||||
export const navigationConfig: NavSection[] = [
|
export const navigationConfig: NavSection[] = [
|
||||||
{
|
{
|
||||||
title: "Main",
|
title: "Main",
|
||||||
links: [
|
links: [
|
||||||
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||||
{ name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
|
{ name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
|
||||||
{ name: "Clients", href: "/dashboard/clients", icon: Users },
|
{ name: "Entities", href: "/dashboard/entities", icon: Users },
|
||||||
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
|
|
||||||
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
||||||
{ name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw },
|
{ name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw },
|
||||||
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
|
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const STORAGE_KEY = "beenvoice:time-clock:last-client";
|
||||||
|
|
||||||
|
export function getLastTimeClockClientId(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLastTimeClockClientId(clientId: string): void {
|
||||||
|
if (!clientId || typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(STORAGE_KEY, clientId);
|
||||||
|
}
|
||||||
+27
-1
@@ -1,3 +1,29 @@
|
|||||||
|
export const DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
||||||
|
|
||||||
|
export function resolveEffectiveHourlyRate(
|
||||||
|
enteredRate: number,
|
||||||
|
client?: { defaultHourlyRate?: number | null } | null,
|
||||||
|
): number {
|
||||||
|
if (Number.isFinite(enteredRate) && enteredRate > 0) return enteredRate;
|
||||||
|
const clientRate = client?.defaultHourlyRate ?? 0;
|
||||||
|
if (Number.isFinite(clientRate) && clientRate > 0) return clientRate;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startedAtFromMinutesAgo(minutes: number): Date {
|
||||||
|
return new Date(Date.now() - minutes * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveClockDescription(
|
||||||
|
title: string,
|
||||||
|
existingDescription?: string | null,
|
||||||
|
): string {
|
||||||
|
const trimmed = title.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
|
if (existingDescription?.trim()) return existingDescription.trim();
|
||||||
|
return DEFAULT_CLOCK_DESCRIPTION;
|
||||||
|
}
|
||||||
|
|
||||||
export type ClockOutOutcome =
|
export type ClockOutOutcome =
|
||||||
| "linked_to_invoice"
|
| "linked_to_invoice"
|
||||||
| "saved_no_invoice"
|
| "saved_no_invoice"
|
||||||
@@ -32,7 +58,7 @@ export function describeClockOutOutcome(input: {
|
|||||||
}
|
}
|
||||||
return `Added ${input.hours}h to invoice`;
|
return `Added ${input.hours}h to invoice`;
|
||||||
case "saved_no_invoice":
|
case "saved_no_invoice":
|
||||||
return `Saved ${input.hours}h — no open invoice found for this client. Pick an invoice on the time clock or create one.`;
|
return `Saved ${input.hours}h — could not create or find a draft invoice for this client.`;
|
||||||
case "saved_no_client":
|
case "saved_no_client":
|
||||||
return `Saved ${input.hours}h — assign a client and invoice to bill this time.`;
|
return `Saved ${input.hours}h — assign a client and invoice to bill this time.`;
|
||||||
case "zero_hours":
|
case "zero_hours":
|
||||||
|
|||||||
+7
-1
@@ -11,7 +11,13 @@ export function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Define public routes that don't require authentication
|
// Define public routes that don't require authentication
|
||||||
const publicRoutes = ["/", "/auth/signin", "/auth/register"];
|
const publicRoutes = [
|
||||||
|
"/",
|
||||||
|
"/auth/signin",
|
||||||
|
"/auth/register",
|
||||||
|
"/privacy",
|
||||||
|
"/terms",
|
||||||
|
];
|
||||||
|
|
||||||
// Define API routes that should be handled separately
|
// Define API routes that should be handled separately
|
||||||
const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp", "/api/i"];
|
const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp", "/api/i"];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
import { invoices, clients } from "~/server/db/schema";
|
import { invoices, clients } from "~/server/db/schema";
|
||||||
import { eq, desc } from "drizzle-orm";
|
import { and, desc, eq, isNotNull, lte } from "drizzle-orm";
|
||||||
|
|
||||||
export const dashboardRouter = createTRPCRouter({
|
export const dashboardRouter = createTRPCRouter({
|
||||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||||
@@ -118,6 +118,26 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sendReminderDue = await ctx.db.query.invoices.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(invoices.createdById, userId),
|
||||||
|
eq(invoices.status, "draft"),
|
||||||
|
isNotNull(invoices.sendReminderAt),
|
||||||
|
lte(invoices.sendReminderAt, now),
|
||||||
|
),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
invoiceNumber: true,
|
||||||
|
invoicePrefix: true,
|
||||||
|
sendReminderAt: true,
|
||||||
|
},
|
||||||
|
with: {
|
||||||
|
client: { columns: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: [desc(invoices.sendReminderAt)],
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalRevenue,
|
totalRevenue,
|
||||||
pendingAmount,
|
pendingAmount,
|
||||||
@@ -129,6 +149,7 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
: 0,
|
: 0,
|
||||||
revenueChartData,
|
revenueChartData,
|
||||||
recentInvoices,
|
recentInvoices,
|
||||||
|
sendReminderDue,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const createInvoiceSchema = z.object({
|
|||||||
emailMessage: 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"),
|
||||||
|
sendReminderAt: z.date().nullable().optional(),
|
||||||
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,13 +156,13 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/** Draft and sent invoices available for time-clock billing. */
|
/** Draft invoices available for time-clock billing. */
|
||||||
getBillable: protectedProcedure
|
getBillable: protectedProcedure
|
||||||
.input(z.object({ clientId: z.string().optional() }).optional())
|
.input(z.object({ clientId: z.string().optional() }).optional())
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(invoices.createdById, ctx.session.user.id),
|
eq(invoices.createdById, ctx.session.user.id),
|
||||||
inArray(invoices.status, ["draft", "sent"]),
|
eq(invoices.status, "draft"),
|
||||||
];
|
];
|
||||||
if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId));
|
if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId));
|
||||||
|
|
||||||
@@ -418,6 +419,23 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (items && existingInvoice.status !== "draft") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Line items can only be edited on draft invoices",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cleanInvoiceData.sendReminderAt !== undefined &&
|
||||||
|
existingInvoice.status !== "draft"
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Send reminders can only be set on draft invoices",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// If business is being updated, verify it belongs to user
|
// If business is being updated, verify it belongs to user
|
||||||
if (
|
if (
|
||||||
cleanInvoiceData.businessId &&
|
cleanInvoiceData.businessId &&
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { eq, and, desc, isNull, isNotNull, gte, lte, or } from "drizzle-orm";
|
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { timeEntries, clients, invoices, invoiceItems } from "~/server/db/schema";
|
import { timeEntries, clients, invoices, invoiceItems, businesses } from "~/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
import {
|
import {
|
||||||
computeTrackedHours,
|
computeTrackedHours,
|
||||||
type ClockOutOutcome,
|
type ClockOutOutcome,
|
||||||
} from "~/lib/time-clock";
|
} from "~/lib/time-clock";
|
||||||
|
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||||
|
|
||||||
type Db = typeof db;
|
type Db = typeof db;
|
||||||
|
|
||||||
@@ -86,6 +87,60 @@ async function addEntryToInvoice(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findOrCreateDraftInvoice(
|
||||||
|
database: Db,
|
||||||
|
userId: string,
|
||||||
|
clientId: string,
|
||||||
|
) {
|
||||||
|
const existing = await database.query.invoices.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(invoices.clientId, clientId),
|
||||||
|
eq(invoices.createdById, userId),
|
||||||
|
eq(invoices.status, "draft"),
|
||||||
|
),
|
||||||
|
with: { items: true },
|
||||||
|
orderBy: [
|
||||||
|
desc(invoices.updatedAt),
|
||||||
|
desc(invoices.issueDate),
|
||||||
|
desc(invoices.invoiceNumber),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const client = await database.query.clients.findFirst({
|
||||||
|
where: and(eq(clients.id, clientId), eq(clients.createdById, userId)),
|
||||||
|
columns: { currency: true },
|
||||||
|
});
|
||||||
|
if (!client) return null;
|
||||||
|
|
||||||
|
const defaultBusiness = await database.query.businesses.findFirst({
|
||||||
|
where: and(eq(businesses.createdById, userId), eq(businesses.isDefault, true)),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const issueDate = new Date();
|
||||||
|
const [created] = await database
|
||||||
|
.insert(invoices)
|
||||||
|
.values({
|
||||||
|
invoiceNumber: generateInvoiceNumber(issueDate),
|
||||||
|
clientId,
|
||||||
|
businessId: defaultBusiness?.id ?? null,
|
||||||
|
issueDate,
|
||||||
|
dueDate: defaultDueDate(issueDate),
|
||||||
|
status: "draft",
|
||||||
|
totalAmount: 0,
|
||||||
|
taxRate: 0,
|
||||||
|
currency: client.currency,
|
||||||
|
createdById: userId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!created) return null;
|
||||||
|
|
||||||
|
return { ...created, items: [] as { amount: number; position: number }[] };
|
||||||
|
}
|
||||||
|
|
||||||
async function addEntryToLatestInvoice(
|
async function addEntryToLatestInvoice(
|
||||||
database: Db,
|
database: Db,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -96,20 +151,7 @@ async function addEntryToLatestInvoice(
|
|||||||
rate: number,
|
rate: number,
|
||||||
date: Date,
|
date: Date,
|
||||||
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
|
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
|
||||||
const invoice = await database.query.invoices.findFirst({
|
const invoice = await findOrCreateDraftInvoice(database, userId, clientId);
|
||||||
where: and(
|
|
||||||
eq(invoices.clientId, clientId),
|
|
||||||
eq(invoices.createdById, userId),
|
|
||||||
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
|
|
||||||
),
|
|
||||||
with: { items: true },
|
|
||||||
orderBy: [
|
|
||||||
desc(invoices.issueDate),
|
|
||||||
desc(invoices.dueDate),
|
|
||||||
desc(invoices.invoiceNumber),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!invoice) return null;
|
if (!invoice) return null;
|
||||||
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
|
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
|
||||||
}
|
}
|
||||||
@@ -128,7 +170,7 @@ async function addEntryToSpecificInvoice(
|
|||||||
where: and(
|
where: and(
|
||||||
eq(invoices.id, invoiceId),
|
eq(invoices.id, invoiceId),
|
||||||
eq(invoices.createdById, userId),
|
eq(invoices.createdById, userId),
|
||||||
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
|
eq(invoices.status, "draft"),
|
||||||
),
|
),
|
||||||
with: { items: true },
|
with: { items: true },
|
||||||
});
|
});
|
||||||
@@ -231,14 +273,14 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
where: and(
|
where: and(
|
||||||
eq(invoices.id, invoiceId),
|
eq(invoices.id, invoiceId),
|
||||||
eq(invoices.createdById, ctx.session.user.id),
|
eq(invoices.createdById, ctx.session.user.id),
|
||||||
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
|
eq(invoices.status, "draft"),
|
||||||
),
|
),
|
||||||
columns: { id: true, clientId: true },
|
columns: { id: true, clientId: true },
|
||||||
});
|
});
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Invoice not found or not open for time tracking",
|
message: "Only draft invoices accept new time entries",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (resolvedClientId && invoice.clientId !== resolvedClientId) {
|
if (resolvedClientId && invoice.clientId !== resolvedClientId) {
|
||||||
@@ -345,14 +387,14 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
where: and(
|
where: and(
|
||||||
eq(invoices.id, invoiceId),
|
eq(invoices.id, invoiceId),
|
||||||
eq(invoices.createdById, ctx.session.user.id),
|
eq(invoices.createdById, ctx.session.user.id),
|
||||||
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
|
eq(invoices.status, "draft"),
|
||||||
),
|
),
|
||||||
columns: { id: true, clientId: true },
|
columns: { id: true, clientId: true },
|
||||||
});
|
});
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Invoice not found or not open for time tracking",
|
message: "Only draft invoices accept new time entries",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (resolvedClientId && invoice.clientId !== resolvedClientId) {
|
if (resolvedClientId && invoice.clientId !== resolvedClientId) {
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ export const invoices = createTable(
|
|||||||
publicToken: d.varchar({ length: 255 }).unique(),
|
publicToken: d.varchar({ length: 255 }).unique(),
|
||||||
publicTokenExpiresAt: d.timestamp(),
|
publicTokenExpiresAt: d.timestamp(),
|
||||||
lastReminderSentAt: d.timestamp(),
|
lastReminderSentAt: d.timestamp(),
|
||||||
|
sendReminderAt: d.timestamp(),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp()
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
|||||||
Reference in New Issue
Block a user