Compare commits

..

10 Commits

Author SHA1 Message Date
soconnor 480c50981d Unify entities navigation, redesign time clock, and add invoice PDF preview.
Combine clients and businesses under entities, polish the web time clock,
and show live invoice PDF preview with tighter line-item editing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 01:08:23 -04:00
soconnor 0b7ffac4e7 Add migration 0016 to fix missing sendReminderAt column.
0015 was already marked applied with the wrong column name, so redeploys
skipped it; 0016 drops send_reminder_at and adds sendReminderAt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:22:11 -04:00
soconnor 9b72afdf69 Fix sendReminderAt migration column name and register in journal.
Use camelCase sendReminderAt to match existing invoice columns; the
snake_case name caused dashboard queries to fail after deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:16:46 -04:00
soconnor 9e7177a869 fix: journal update 2026-06-22 16:12:09 -04:00
soconnor 1928084acb Add draft-only invoicing rules, send reminders, and time clock billing.
Restrict line item edits to draft invoices, auto-create drafts on clock-out,
and add sendReminderAt scheduling with dashboard due reminders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:06:11 -04:00
soconnor 4cd8ad3c4c Redesign legal pages with readable paragraph layout and updated contact info.
Privacy Policy and Terms now share a document layout with table of contents,
plain-paragraph copy, and beenvoice.soconnor.dev contact details for App Store review.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 02:44:43 -04:00
soconnor 5019a7597d Expose public auth capabilities endpoint for mobile SSO detection.
Returns whether Authentik and signups are enabled so the app can show the right
sign-in options per instance at runtime.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 02:27:31 -04:00
soconnor 40020b78f8 fix: auth lock privacy 2026-06-18 01:45:18 -04:00
soconnor 69da2bf71d Add shared legal pages and wire Privacy Policy and Terms across the app.
Extract privacy and terms content into reusable components, replace auth modals with links to /privacy and /terms, add settings legal section, and remove duplicate legal-modal markup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 01:33:34 -04:00
soconnor b7380f4348 Add demo account seed migration for App Store review.
Seeds demo@example.com with sample clients, business, and invoices via the Drizzle journal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 23:14:45 -04:00
52 changed files with 2225 additions and 1569 deletions
+2
View File
@@ -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.
+36 -38
View File
@@ -1,14 +1,29 @@
![beenvoice Logo](public/beenvoice-logo.png) ![beenvoice Logo](public/beenvoice-logo.png)
# 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.
![beenvoice Logo](https://img.shields.io/badge/beenvoice-Invoicing%20Made%20Simple-green?style=for-the-badge) **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
+211
View File
@@ -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 (00000014+)
```
## 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`
+33
View File
@@ -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 |
+172
View File
@@ -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;
+21
View File
@@ -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
} }
] ]
} }
+18
View File
@@ -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;
}
+16 -385
View File
@@ -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 (&quot;we&quot;, &quot;our&quot;, or &quot;us&quot;)
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 &quot;Contact Us&quot; 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&apos;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
View File
@@ -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 (&quot;Terms&quot;) govern your use of
the beenvoice platform and services (the &quot;Service&quot;)
operated by beenvoice (&quot;us&quot;, &quot;we&quot;, or
&quot;our&quot;).
</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 &quot;as
is&quot; 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>
); );
} }
+10
View File
@@ -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,
});
}
+5 -22
View File
@@ -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>
+2 -14
View File
@@ -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>
+5 -22
View File
@@ -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>
+2 -14
View File
@@ -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>
+1 -1
View File
@@ -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} />;
}
+3 -38
View File
@@ -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>
);
} }
+1 -1
View File
@@ -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>
+3 -27
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+91 -1
View File
@@ -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">
+1 -1
View File
@@ -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
View File
@@ -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>
+3 -3
View File
@@ -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 (
+3 -3
View File
@@ -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) {
+34 -23
View File
@@ -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>
+35 -51
View File
@@ -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}>
+110 -113
View File
@@ -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>
);
}
+2 -2
View File
@@ -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 (
+101
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+50
View File
@@ -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 Services 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"
}`} }`}
+347 -124
View File
@@ -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" />
-350
View File
@@ -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 (&quot;Terms&quot;) govern your use of the
beenvoice platform and services (the &quot;Service&quot;) operated
by beenvoice (&quot;us&quot;, &quot;we&quot;, or &quot;our&quot;).
</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>
);
}
+11
View File
@@ -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;
}
+5
View File
@@ -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
View File
@@ -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 },
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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"];
+22 -1
View File
@@ -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,
}; };
}), }),
}); });
+20 -2
View File
@@ -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 &&
+63 -21
View File
@@ -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) {
+1
View File
@@ -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`)