Compare commits

..

35 Commits

Author SHA1 Message Date
hwinkel 1f4005f5d4 fix: streamline Docker image build process by removing unnecessary load and save steps
Build and Push Docker Image / test (push) Successful in 28s
Build and Push Docker Image / build (push) Successful in 1m51s
2026-05-09 15:42:09 +02:00
hwinkel 0547f6a41c fix: downgrade upload and download artifact actions to v3 for compatibility
Build and Push Docker Image / test (push) Successful in 37s
Build and Push Docker Image / build (push) Successful in 3m1s
Build and Push Docker Image / push (push) Failing after 46s
2026-05-09 15:32:37 +02:00
hwinkel 28674fb023 fix: ensure Docker image name is lowercase when saving
Build and Push Docker Image / test (push) Successful in 28s
Build and Push Docker Image / build (push) Failing after 2m8s
Build and Push Docker Image / push (push) Has been skipped
2026-05-09 15:27:39 +02:00
hwinkel b640cfdb74 fix: update Docker image saving command for consistency
Build and Push Docker Image / test (push) Successful in 28s
Build and Push Docker Image / build (push) Failing after 1m54s
Build and Push Docker Image / push (push) Has been skipped
2026-05-09 15:23:14 +02:00
hwinkel d076fba0c0 fix: add no-cache option to Docker build step and update .gitignore for new files
Build and Push Docker Image / test (push) Successful in 28s
Build and Push Docker Image / build (push) Failing after 1m57s
Build and Push Docker Image / push (push) Has been skipped
2026-05-09 15:15:39 +02:00
hwinkel f155a7952a feat: split Gitea workflow into test/build/push jobs
Build and Push Docker Image / test (push) Successful in 30s
Build and Push Docker Image / build (push) Failing after 44s
Build and Push Docker Image / push (push) Has been skipped
2026-05-09 12:32:36 +02:00
hwinkel 0e62f0f80b fix: update default category counts for expenses and revenues in tests
Build and Push Docker Image / build (push) Successful in 2m5s
2026-05-09 12:00:23 +02:00
hwinkel 29619658fc feat: add graphify plugin and documentation
Build and Push Docker Image / build (push) Failing after 1m35s
- Introduced a new graphify OpenCode plugin to remind users about the knowledge graph before executing bash commands.
- Added AGENTS.md for agent guidance, including development commands, environment setup, Docker deployment, code structure, conventions, and testing instructions.
- Created opencode.json to configure the graphify plugin and superpowers.
- Updated tests to improve type safety and added missing imports in test files.
- Added .graphify_uncached.txt to track relevant files for graphify.
2026-05-09 11:52:16 +02:00
hwinkel db953b1e28 Add comprehensive tests for client validation, revenue and expense categories, invoice generation, and schemas
- Implement tests for client validation functions including tax ID, VAT ID, IBAN, BIC, website, and company form validation.
- Create tests for revenue and expense categories ensuring all expected categories and labels are present.
- Add tests for invoice number generation with various scenarios including prefix handling and sequence padding.
- Introduce tests for default categories and their integration, ensuring no overlaps and consistent naming conventions.
- Implement Zod schema validation tests for currency, tax rates, IBAN, tax ID, VAT ID, invoices, companies, and customers.
- Add utility tests for tax calculations, including item amounts and invoice totals, ensuring correct handling of tax rates and formatting.
- Set up Vitest configuration and global test setup for consistent testing environment.
2026-05-08 16:03:05 +02:00
hwinkel 586d5b2cc8 feat: add .continue to .gitignore for improved file management 2026-05-08 15:02:59 +02:00
hwinkel 38c8304336 feat: update AUTH_SECRET handling and improve session management
Build and Push Docker Image / build (push) Successful in 1m23s
fix: add credentials to POST request in NewCompanyPage
fix: update Docker image pull policy for app service
2026-05-03 09:24:41 +02:00
hwinkel b22e5baa5c feat: add client-side validation utilities and debugging tools
Build and Push Docker Image / build (push) Successful in 1m23s
- Implemented client-side validation functions for tax ID, VAT ID, IBAN, BIC, and website URL.
- Added debug logging functionality to assist in development.
- Created a comprehensive validation function for company form data.

feat: initialize database with Prisma migrations

- Added a server-side script to run Prisma migrations and check database health.
- Ensured safe initialization of the database to prevent concurrent migrations.

feat: comprehensive server-side error logging

- Developed an error logging system that captures detailed error context, including request details and stack traces.
- Implemented logging functions for different error types (route, action, database, API, startup).

fix: validate user ID existence in audit logs

- Updated the logging function to validate that the user ID exists in the database before logging actions.

fix: update schemas for optional fields and validation

- Modified schemas to allow for nullable fields and refined validation logic for tax ID, VAT ID, IBAN, and BIC.

feat: enhance error boundary for better debugging

- Improved error boundary to log detailed error information in development mode.
- Added a debug panel to the main application layout for real-time error tracking.

feat: implement company deletion functionality in admin routes

- Added a new API route for deleting companies with appropriate logging.
- Integrated delete confirmation in the admin interface for better user experience.

fix: handle API errors gracefully

- Wrapped API actions in try-catch blocks to log errors and return appropriate responses.

feat: generate and save invoice PDFs

- Implemented functionality to generate and save invoice PDFs upon status updates.
- Added a new column in the database for storing the URL of the generated PDF.

chore: update Docker image reference

- Changed the Docker image reference to point to the new Git repository.

chore: update package dependencies

- Added @radix-ui/react-tooltip for enhanced UI components.
- Updated package-lock.json to reflect new dependencies.
2026-05-03 08:46:58 +02:00
hwinkel c3e7a97c8a fix: update login rate limit to allow 15 attempts within 3 minutes
Build and Push Docker Image / build (push) Successful in 1m30s
2026-05-02 21:44:12 +02:00
hwinkel fab53fc76e feat: add receipt upload functionality for Einnahmen and Beleg routes
Build and Push Docker Image / build (push) Successful in 1m34s
- Implemented upload and retrieval of receipts (Belege) associated with Einnahmen entries.
- Added new API routes for uploading and deleting receipts.
- Updated the Einnahmen model to include a `belegUrl` field for storing receipt references.
- Enhanced the Einnahmen page to support file uploads and display existing receipts.
- Introduced drag-and-drop functionality for file uploads and improved user feedback during uploads.
- Added necessary validation for file types and sizes during uploads.
2026-04-29 20:49:57 +02:00
hwinkel f93eb0480a docs: add CI/CD section to README with Gitea Actions details
Build and Push Docker Image / build (push) Successful in 1m31s
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 14:09:56 +02:00
hwinkel ac9912fa81 fix: update Docker registry URL for consistency in build workflow
Build and Push Docker Image / build (push) Has been cancelled
2026-04-25 14:04:48 +02:00
hwinkel d56144b39c fix: streamline Docker registry configuration for improved login and metadata extraction
Build and Push Docker Image / build (push) Has been cancelled
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 14:01:14 +02:00
hwinkel f67da67abd fix: update Docker registry configuration for improved login and metadata extraction
Build and Push Docker Image / build (push) Failing after 25s
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 13:57:28 +02:00
hwinkel e526e7fd2d fix: improve openssl installation logic in Dockerfile for better compatibility
Build and Push Docker Image / build (push) Failing after 5m8s
2026-04-25 13:45:08 +02:00
hwinkel c412388877 fix: update Docker login credentials to use repository owner and registry token
Build and Push Docker Image / build (push) Failing after 41s
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 13:39:58 +02:00
hwinkel 73ad126052 fix: update password reference to use secrets in build workflow
Build and Push Docker Image / build (push) Failing after 30s
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 13:34:25 +02:00
henry 662e565116 .gitea/workflows/build.yml aktualisiert
Build and Push Docker Image / build (push) Failing after 30s
2026-04-25 11:18:44 +00:00
henry 80791e4aa7 .gitea/workflows/build.yml aktualisiert
Build and Push Docker Image / build (push) Failing after 31s
2026-04-25 11:11:23 +00:00
hwinkel 8d5f69aaab chore: comment out REGISTRY environment variable in build workflow
Build and Push Docker Image / build (push) Failing after 30s
2026-04-25 13:09:31 +02:00
hwinkel 7af907a801 feat: add GitHub Actions workflow for building and pushing Docker images
Build and Push Docker Image / build (push) Failing after 30s
2026-04-25 12:20:01 +02:00
hwinkel ad80688b8b Refactor: consolidate accounting routes under Buchhaltung submenu
- New layout route: companies.$id.buchhaltung.tsx with card-based navigation
- Renamed 7 accounting routes to use buchhaltung prefix:
  - companies.$id.bilanzen.tsx → companies.$id.buchhaltung.bilanzen.tsx
  - companies.$id.ausgaben.tsx → companies.$id.buchhaltung.ausgaben.tsx
  - companies.$id.ausgaben.kategorien.tsx → companies.$id.buchhaltung.ausgaben.kategorien.tsx
  - companies.$id.einnahmen.tsx → companies.$id.buchhaltung.einnahmen.tsx
  - companies.$id.einnahmen.kategorien.tsx → companies.$id.buchhaltung.einnahmen.kategorien.tsx
  - companies.$id.anlagevermoegen.tsx → companies.$id.buchhaltung.anlagevermoegen.tsx
  - companies.$id.money.tsx → companies.$id.buchhaltung.money.tsx

- Updated routing configuration (app/routes.ts) to use nested layout structure
- Updated breadcrumbs in all accounting routes to show Buchhaltung hierarchy
- Updated internal links in kategorien pages to use new URLs
- Main menu now shows single 'Buchhaltung' card instead of 5 separate items

Navigation improvements:
- Cleaner main menu (1 item vs 5)
- Clear accounting subsection with icon-based navigation
- Consistent URL structure (/companies/:id/buchhaltung/*)
- Better information hierarchy

Build:  Successful
Accounting routes:  Accessible
Navigation:  Functional

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 21:41:56 +02:00
hwinkel f10a79471e Refactor: centralize Zod schemas and fully integrate into API routes
Improvements #1-3 deepening:

1. Server-side invoice amount validation
   - All amounts (qty × unitPrice) recalculated server-side using tax.ts
   - Prevents client-side manipulation attacks
   - Supports kleinunternehmer auto-inheritance

2. Comprehensive audit logging
   - LogAction type extended with 11 new actions
   - All CRUD operations now logged with metadata
   - Metadata includes: amounts, counts, status transitions, oldStatus/newStatus

3. Advanced Zod validation (centralized)
   - New file: app/lib/schemas.ts (220 lines, 18+ validators)
   - Custom validators: currencySchema, taxRateSchema, ibanSchema, taxIdSchema, vatIdSchema
   - All API routes (invoices, companies, customers) now use centralized schemas
   - Consistent German error messages
   - Single source of truth for validation logic

Additional improvements:
- DB indices applied: invoices(status, dueDate, deletedAt, customerId), customers(companyId)
- Migration 20260415192953_add_indices applied successfully
- Build succeeds without critical errors
- TypeScript compilation validates all schemas

Files modified:
- app/lib/schemas.ts (NEW)
- app/routes/api.invoices.ts (uses centralized schemas)
- app/routes/api.invoices.$id.ts (status transition validation)
- app/routes/api.companies.ts, api.companies.$id.ts
- app/routes/api.customers.ts, api.customers.$id.ts
- app/lib/logger.server.ts (metadata support)
- prisma/schema.prisma (indices)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 21:34:38 +02:00
hwinkel 1ffbcf237c chore: update .gitignore to include graphify-out directory and add copilot instructions 2026-04-13 21:50:41 +02:00
hwinkel 9e7c85c2b3 feat: add Einnahmen Kategorien management page with CRUD functionality
- Implemented a new route for managing Einnahmen Kategorien.
- Added auto-seeding of default Einnahmen Kategorien if none exist.
- Integrated category usage tracking to prevent deletion of in-use categories.
- Enhanced Einnahmen page to link to the new Kategorien management.
- Updated Prisma schema and seed script to include default categories.
- Added a modal for detailed view of Einnahmen by category and month.
- Refactored existing Einnahmen page to accommodate new category structure.
- Introduced PostCSS configuration for Tailwind CSS support.
- Created a new migration to update existing category labels in the database.
- Added TypeScript configuration for stricter type checking.
- Set up Vite configuration for improved development experience with React Router.
2026-03-24 22:43:09 +01:00
hwinkel 1ec15600b5 Refactor financial transaction handling: Consolidate Einnahmen and Ausgaben into Buchung model, update routes and UI components, and add new migration scripts for database schema changes. 2026-03-24 21:06:07 +01:00
hwinkel d582c748a2 feat: add financial transactions management for companies
- Implemented a new route for managing financial transactions (money) for companies, including creating, editing, and deleting transactions.
- Added a new model `Buchung` to represent transactions with fields for date, account type, transaction type, amount, and description.
- Updated the `companies` model to include a relation to the new `Buchung` model.
- Enhanced the company overview page to link to the new financial transactions page.
- Added migration scripts to create the necessary database tables and fields for the new functionality.
- Created utility scripts for resetting the admin password and setting up the initial admin user.
2026-03-24 19:25:48 +01:00
hwinkel 6d8c4b615f ADD: added einnahmen, ausgaben and bilanz 2026-03-24 14:48:32 +01:00
hwinkel 1bbeaf2c34 FIX: fixed erechnung error 2026-03-15 21:05:45 +01:00
hwinkel c6dc22c859 ADD: fixed e rechnung 2026-03-15 20:58:24 +01:00
hwinkel 5ac9e269e3 ADD: added e-rechnung 2026-03-15 20:21:48 +01:00
158 changed files with 38928 additions and 2981 deletions
-11
View File
@@ -1,11 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npx react-router typegen)",
"Bash(npx react-router build)"
],
"additionalDirectories": [
"/home/henry/.claude/projects/-home-henry-code-AnnasRechnungsManager"
]
}
}
+16 -2
View File
@@ -1,3 +1,17 @@
# Datenbank (für lokale Entwicklung)
DATABASE_URL="mysql://user:password@localhost:3306/annas_rechnungsmanager"
AUTH_SECRET="your-random-secret-here"
NEXTAUTH_URL="http://localhost:3000"
# Session-Secret optional
# Wenn nicht gesetzt, wird ein zufälliger Wert generiert (empfohlen für Docker/Development)
# Bei Containerneustarts werden alle Sessions dann automatisch ungültig
# Für Production mit persistenter Session: openssl rand -base64 32
AUTH_SECRET=""
# Docker-Compose: Datenbank-Credentials
DB_ROOT_PASSWORD="sicheres_root_passwort"
DB_USER="annas_user"
DB_PASSWORD="sicheres_db_passwort"
DB_NAME="annas_rechnungen"
# Docker-Compose: Admin-Passwort (nur beim ersten Start relevant)
ADMIN_PASSWORD="sicheres_admin_passwort"
+80
View File
@@ -0,0 +1,80 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
env:
REGISTRY: git.henryathome.home64.de
IMAGE_NAME: ${{ gitea.repository }}
jobs:
test:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test
build:
permissions:
contents: read
packages: write
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.repository_owner }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
no-cache: true
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+130
View File
@@ -0,0 +1,130 @@
# Copilot Instructions — Annas Rechnungsmanager
German accounting & invoice management system for tax consultants (Steuerberater), built with React Router v7 (SSR), Prisma, MariaDB, and Tailwind CSS v4.
---
## Commands
```bash
npm run dev # Dev server at http://localhost:5173
npm run build # Production build
npm run typecheck # react-router typegen + tsc (run before every commit)
npm run lint # ESLint
npm run db:migrate # prisma migrate dev (create + apply migration)
npm run db:seed # Seed database with sample data
npm run db:studio # Prisma Studio GUI
npm run setup-admin # Create initial admin user
npm run reset-password
```
**Required `.env` variables:**
```env
DATABASE_URL="mysql://root:password@localhost:3306/annas_rechnungsmanager"
AUTH_SECRET="<random 32-byte hex>" # NOT SESSION_SECRET
NODE_ENV="development"
```
---
## Architecture
### Route Layout
Routes are **explicitly configured** in `app/routes.ts` (not auto-discovered by file name). Two layout wrappers exist:
- `dashboard-layout.tsx` — wraps all authenticated company/user routes
- `admin-layout.tsx` — wraps `/admin/*` (requires ADMIN role)
**UI routes** (`app/routes/companies.*.tsx`, etc.) export `loader` + default component.
**API routes** (`app/routes/api.*.ts`) export only `action` (no default export, no loader).
### Path Alias
`@/` resolves to `app/`. Use it everywhere: `import prisma from "@/lib/prisma.server"`.
### Data Flow Pattern
```
UI Route (loader/action)
→ app/lib/ (business logic + Prisma queries)
→ prisma.server.ts (single Prisma Client instance)
→ AuditLog (mandatory on every write)
```
Auth helpers live in `app/session.server.ts`:
- `requireUser(request)` — throws redirect to `/login` if not authenticated
- `requireAdmin(request)` — throws redirect to `/` if not ADMIN
- `getApiUser(request)` — returns user or null (for API routes)
---
## Key Conventions
### Input Validation
All Zod schemas live in `app/lib/schemas.ts`. Reusable validators (`currencySchema`, `taxRateSchema`, `ibanSchema`, `vatIdSchema`) are defined there — import and extend them, don't redefine.
API routes must `safeParse` the request body before any DB access:
```ts
const parsed = mySchema.safeParse(await request.json());
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
```
### Tax Calculations (Never Trust the Client)
Always **recalculate amounts server-side** using `app/lib/tax.ts`:
- `calcItemAmounts(quantity, unitPrice, taxRate)` — standard MwSt.
- `calcItemAmountsKleinunternehmer(quantity, unitPrice)` — §19 UStG, no VAT
- `calcInvoiceTotals(items)` — sums net/tax/gross
Valid tax rates: `0`, `7`, `19` (enforced by `taxRateSchema`).
The `Company.kleinunternehmer` flag controls which calculation is used — always read it from the DB, not from the request body.
### Audit Logging (Mandatory on Every Write)
Every mutation must call `log()` from `app/lib/logger.server.ts`. The `action` parameter must use the `LogAction` union type defined in that file:
```ts
await log({ userId: user.id, action: "CREATE_INVOICE", entity: "Invoice", entityId: invoice.id, metadata: {...}, request });
```
Logging failures are swallowed intentionally — never let them break the main operation.
### Database Rules
- All Prisma queries belong in `app/lib/`, not in routes or components.
- Use `prisma.$transaction()` for multi-step operations (e.g., creating an invoice + a Buchung entry).
- Invoice amounts are stored as both net/tax/gross (`Decimal(10,2)`) — always persist all three.
- Soft-delete pattern: invoices use `deletedAt` field; companies use `archived`/`archivedAt`.
- When an invoice is marked PAID, a linked `Buchung` record is created automatically.
### Domain Vocabulary (German ↔ Code)
| German | Code / Table |
|--------|--------------|
| Mandant | `Company` model |
| Buchung | `Buchung` model (transaction/ledger entry) |
| Anlagegut | `Anlagegut` model (fixed asset) |
| Ausgabe | expense (type `ENTNAHME` in `Buchung`) |
| Einnahme | revenue (type `EINLAGE` in `Buchung`) |
| Kleinunternehmer | §19 UStG — no VAT on invoices |
| AFA | Absetzung für Abnutzung — depreciation, computed in `app/lib/afa.ts` |
### Sessions
Session cookie name: `__session`, expires after **4 hours**, stored via `createCookieSessionStorage`. Keys stored in session: `userId`, `userName`, `userRole`.
---
## High-Impact Files
Changes to these files have wide blast radius — check dependents carefully:
| File | Why it matters |
|------|----------------|
| `app/session.server.ts` | Auth used by every protected route |
| `app/lib/tax.ts` | Tax calculations used in invoices, reports, exports |
| `app/lib/afa.ts` | Depreciation logic used in reports and asset management |
| `prisma/schema.prisma` | Any change requires a migration |
| `app/routes.ts` | Route config — adding routes here is required, not automatic |
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--browser", "chromium"]
}
}
}
+33
View File
@@ -0,0 +1,33 @@
name: "Copilot Setup Steps"
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
+12
View File
@@ -45,3 +45,15 @@ next-env.d.ts
/src/generated/prisma
/db/data
/graphify-out
# Uploaded Belege (persistent volume in production)
data/documents/
.vscode/
.react-router
.continue
.opencode
docs/superpowers
+9489
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16465
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
AGENTS.md
copilot-instructions.md
README.md
CLAUDE.md
IMPROVEMENTS_SUMMARY.md
INTEGRATION_EXAMPLE.md
tests/README.md
app/lib/ERROR_LOGGING_GUIDE.md
graphify-out/GRAPH_REPORT.md
data/documents/cmmoo4v5p0000ykou0bcivjsn/migr-ein-cmn4ogjc20007dfmmex6engdh-1777488241127.pdf
data/documents/cmmoo4v5p0000ykou0bcivjsn/migr-ein-cmn4ogjc20007dfmmex6engdh-1777488343334.pdf
data/documents/cmootxs7r0000nvgjmhgbanvx/demo-invoice-1-1777790365605.pdf
data/documents/cmootxs7r0000nvgjmhgbanvx/cmopen7qx000s2mzpnq44y0rb-1777790640988.pdf
data/documents/cmootxs7r0000nvgjmhgbanvx/cmopejaq8000f2mzpnho6t72z-1777790461825.pdf
public/file.svg
public/window.svg
public/globe.svg
public/next.svg
public/vercel.svg
coverage/favicon.png
coverage/sort-arrow-sprite.png
+22
View File
@@ -0,0 +1,22 @@
// graphify OpenCode plugin
// Injects a knowledge graph reminder before bash tool calls when the graph exists.
import { existsSync } from "fs";
import { join } from "path";
export const GraphifyPlugin = async ({ directory }) => {
let reminded = false;
return {
"tool.execute.before": async (input, output) => {
if (reminded) return;
if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
if (input.tool === "bash") {
output.args.command =
'echo "[graphify] Knowledge graph available. Read graphify-out/GRAPH_REPORT.md for god nodes and architecture context before searching files." && ' +
output.args.command;
reminded = true;
}
},
};
};
@@ -0,0 +1 @@
[ 421ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0
@@ -0,0 +1,19 @@
- generic [ref=e4]:
- generic [ref=e5]:
- img [ref=e7]
- generic [ref=e9]: Rechnungsmanager
- generic [ref=e10]:
- heading "Willkommen zurück" [level=1] [ref=e11]
- paragraph [ref=e12]: Benutzername oder E-Mail und Passwort eingeben
- generic [ref=e14]:
- generic [ref=e15]:
- text: Benutzername oder E-Mail
- textbox "Benutzername oder E-Mail" [ref=e16]:
- /placeholder: anna oder anna@example.de
- generic [ref=e17]:
- text: Passwort
- generic [ref=e18]:
- textbox "Passwort" [ref=e19]
- button "Passwort anzeigen" [ref=e20]:
- img [ref=e21]
- button "Anmelden" [ref=e24]
-9
View File
@@ -1,9 +0,0 @@
// Generated by React Router
import "react-router";
declare module "react-router" {
interface Future {
v8_middleware: false
}
}
-327
View File
@@ -1,327 +0,0 @@
// Generated by React Router
import "react-router"
declare module "react-router" {
interface Register {
pages: Pages
routeFiles: RouteFiles
routeModules: RouteModules
}
}
type Pages = {
"/": {
params: {};
};
"/login": {
params: {};
};
"/logout": {
params: {};
};
"/companies": {
params: {};
};
"/companies/new": {
params: {};
};
"/companies/:id": {
params: {
"id": string;
};
};
"/companies/:id/edit": {
params: {
"id": string;
};
};
"/companies/:id/customers": {
params: {
"id": string;
};
};
"/companies/:id/leistungen": {
params: {
"id": string;
};
};
"/companies/:id/invoices": {
params: {
"id": string;
};
};
"/companies/:id/invoices/new": {
params: {
"id": string;
};
};
"/companies/:id/invoices/:invoiceId": {
params: {
"id": string;
"invoiceId": string;
};
};
"/companies/:id/invoices/:invoiceId/edit": {
params: {
"id": string;
"invoiceId": string;
};
};
"/companies/:id/reports": {
params: {
"id": string;
};
};
"/archiv": {
params: {};
};
"/settings/password": {
params: {};
};
"/admin/users": {
params: {};
};
"/admin/users/new": {
params: {};
};
"/admin/users/:id": {
params: {
"id": string;
};
};
"/admin/logs": {
params: {};
};
"/api/companies": {
params: {};
};
"/api/companies/:id": {
params: {
"id": string;
};
};
"/api/companies/:id/customers": {
params: {
"id": string;
};
};
"/api/companies/:id/invoices": {
params: {
"id": string;
};
};
"/api/customers": {
params: {};
};
"/api/customers/:id": {
params: {
"id": string;
};
};
"/api/services": {
params: {};
};
"/api/services/:id": {
params: {
"id": string;
};
};
"/api/invoices": {
params: {};
};
"/api/invoices/:id": {
params: {
"id": string;
};
};
"/api/invoices/:id/pdf": {
params: {
"id": string;
};
};
"/api/reports": {
params: {};
};
};
type RouteFiles = {
"root.tsx": {
id: "root";
page: "/" | "/login" | "/logout" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/leistungen" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/invoices/:invoiceId/edit" | "/companies/:id/reports" | "/archiv" | "/settings/password" | "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs" | "/api/companies" | "/api/companies/:id" | "/api/companies/:id/customers" | "/api/companies/:id/invoices" | "/api/customers" | "/api/customers/:id" | "/api/services" | "/api/services/:id" | "/api/invoices" | "/api/invoices/:id" | "/api/invoices/:id/pdf" | "/api/reports";
};
"routes/login.tsx": {
id: "routes/login";
page: "/login";
};
"routes/logout.ts": {
id: "routes/logout";
page: "/logout";
};
"routes/dashboard-layout.tsx": {
id: "routes/dashboard-layout";
page: "/" | "/companies" | "/companies/new" | "/companies/:id" | "/companies/:id/edit" | "/companies/:id/customers" | "/companies/:id/leistungen" | "/companies/:id/invoices" | "/companies/:id/invoices/new" | "/companies/:id/invoices/:invoiceId" | "/companies/:id/invoices/:invoiceId/edit" | "/companies/:id/reports" | "/archiv" | "/settings/password";
};
"routes/home.tsx": {
id: "routes/home";
page: "/";
};
"routes/companies.tsx": {
id: "routes/companies";
page: "/companies";
};
"routes/companies.new.tsx": {
id: "routes/companies.new";
page: "/companies/new";
};
"routes/companies.$id.tsx": {
id: "routes/companies.$id";
page: "/companies/:id";
};
"routes/companies.$id.edit.tsx": {
id: "routes/companies.$id.edit";
page: "/companies/:id/edit";
};
"routes/companies.$id.customers.tsx": {
id: "routes/companies.$id.customers";
page: "/companies/:id/customers";
};
"routes/companies.$id.leistungen.tsx": {
id: "routes/companies.$id.leistungen";
page: "/companies/:id/leistungen";
};
"routes/companies.$id.invoices.tsx": {
id: "routes/companies.$id.invoices";
page: "/companies/:id/invoices";
};
"routes/companies.$id.invoices.new.tsx": {
id: "routes/companies.$id.invoices.new";
page: "/companies/:id/invoices/new";
};
"routes/companies.$id.invoices.$invoiceId.tsx": {
id: "routes/companies.$id.invoices.$invoiceId";
page: "/companies/:id/invoices/:invoiceId";
};
"routes/companies.$id.invoices.$invoiceId.edit.tsx": {
id: "routes/companies.$id.invoices.$invoiceId.edit";
page: "/companies/:id/invoices/:invoiceId/edit";
};
"routes/companies.$id.reports.tsx": {
id: "routes/companies.$id.reports";
page: "/companies/:id/reports";
};
"routes/archiv.tsx": {
id: "routes/archiv";
page: "/archiv";
};
"routes/settings.password.tsx": {
id: "routes/settings.password";
page: "/settings/password";
};
"routes/admin-layout.tsx": {
id: "routes/admin-layout";
page: "/admin/users" | "/admin/users/new" | "/admin/users/:id" | "/admin/logs";
};
"routes/admin.users.tsx": {
id: "routes/admin.users";
page: "/admin/users";
};
"routes/admin.users.new.tsx": {
id: "routes/admin.users.new";
page: "/admin/users/new";
};
"routes/admin.users.$id.tsx": {
id: "routes/admin.users.$id";
page: "/admin/users/:id";
};
"routes/admin.logs.tsx": {
id: "routes/admin.logs";
page: "/admin/logs";
};
"routes/api.companies.ts": {
id: "routes/api.companies";
page: "/api/companies";
};
"routes/api.companies.$id.ts": {
id: "routes/api.companies.$id";
page: "/api/companies/:id";
};
"routes/api.companies.$id.customers.ts": {
id: "routes/api.companies.$id.customers";
page: "/api/companies/:id/customers";
};
"routes/api.companies.$id.invoices.ts": {
id: "routes/api.companies.$id.invoices";
page: "/api/companies/:id/invoices";
};
"routes/api.customers.ts": {
id: "routes/api.customers";
page: "/api/customers";
};
"routes/api.customers.$id.ts": {
id: "routes/api.customers.$id";
page: "/api/customers/:id";
};
"routes/api.services.ts": {
id: "routes/api.services";
page: "/api/services";
};
"routes/api.services.$id.ts": {
id: "routes/api.services.$id";
page: "/api/services/:id";
};
"routes/api.invoices.ts": {
id: "routes/api.invoices";
page: "/api/invoices";
};
"routes/api.invoices.$id.ts": {
id: "routes/api.invoices.$id";
page: "/api/invoices/:id";
};
"routes/api.invoices.$id.pdf.ts": {
id: "routes/api.invoices.$id.pdf";
page: "/api/invoices/:id/pdf";
};
"routes/api.reports.ts": {
id: "routes/api.reports";
page: "/api/reports";
};
};
type RouteModules = {
"root": typeof import("./app/root.tsx");
"routes/login": typeof import("./app/routes/login.tsx");
"routes/logout": typeof import("./app/routes/logout.ts");
"routes/dashboard-layout": typeof import("./app/routes/dashboard-layout.tsx");
"routes/home": typeof import("./app/routes/home.tsx");
"routes/companies": typeof import("./app/routes/companies.tsx");
"routes/companies.new": typeof import("./app/routes/companies.new.tsx");
"routes/companies.$id": typeof import("./app/routes/companies.$id.tsx");
"routes/companies.$id.edit": typeof import("./app/routes/companies.$id.edit.tsx");
"routes/companies.$id.customers": typeof import("./app/routes/companies.$id.customers.tsx");
"routes/companies.$id.leistungen": typeof import("./app/routes/companies.$id.leistungen.tsx");
"routes/companies.$id.invoices": typeof import("./app/routes/companies.$id.invoices.tsx");
"routes/companies.$id.invoices.new": typeof import("./app/routes/companies.$id.invoices.new.tsx");
"routes/companies.$id.invoices.$invoiceId": typeof import("./app/routes/companies.$id.invoices.$invoiceId.tsx");
"routes/companies.$id.invoices.$invoiceId.edit": typeof import("./app/routes/companies.$id.invoices.$invoiceId.edit.tsx");
"routes/companies.$id.reports": typeof import("./app/routes/companies.$id.reports.tsx");
"routes/archiv": typeof import("./app/routes/archiv.tsx");
"routes/settings.password": typeof import("./app/routes/settings.password.tsx");
"routes/admin-layout": typeof import("./app/routes/admin-layout.tsx");
"routes/admin.users": typeof import("./app/routes/admin.users.tsx");
"routes/admin.users.new": typeof import("./app/routes/admin.users.new.tsx");
"routes/admin.users.$id": typeof import("./app/routes/admin.users.$id.tsx");
"routes/admin.logs": typeof import("./app/routes/admin.logs.tsx");
"routes/api.companies": typeof import("./app/routes/api.companies.ts");
"routes/api.companies.$id": typeof import("./app/routes/api.companies.$id.ts");
"routes/api.companies.$id.customers": typeof import("./app/routes/api.companies.$id.customers.ts");
"routes/api.companies.$id.invoices": typeof import("./app/routes/api.companies.$id.invoices.ts");
"routes/api.customers": typeof import("./app/routes/api.customers.ts");
"routes/api.customers.$id": typeof import("./app/routes/api.customers.$id.ts");
"routes/api.services": typeof import("./app/routes/api.services.ts");
"routes/api.services.$id": typeof import("./app/routes/api.services.$id.ts");
"routes/api.invoices": typeof import("./app/routes/api.invoices.ts");
"routes/api.invoices.$id": typeof import("./app/routes/api.invoices.$id.ts");
"routes/api.invoices.$id.pdf": typeof import("./app/routes/api.invoices.$id.pdf.ts");
"routes/api.reports": typeof import("./app/routes/api.reports.ts");
};
-18
View File
@@ -1,18 +0,0 @@
// Generated by React Router
declare module "virtual:react-router/server-build" {
import { ServerBuild } from "react-router";
export const assets: ServerBuild["assets"];
export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"];
export const basename: ServerBuild["basename"];
export const entry: ServerBuild["entry"];
export const future: ServerBuild["future"];
export const isSpaMode: ServerBuild["isSpaMode"];
export const prerender: ServerBuild["prerender"];
export const publicPath: ServerBuild["publicPath"];
export const routeDiscovery: ServerBuild["routeDiscovery"];
export const routes: ServerBuild["routes"];
export const ssr: ServerBuild["ssr"];
export const allowedActionOrigins: ServerBuild["allowedActionOrigins"];
export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"];
}
-59
View File
@@ -1,59 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../root.js")
type Info = GetInfo<{
file: "root.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../root.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../admin-layout.js")
type Info = GetInfo<{
file: "routes/admin-layout.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/admin-layout";
module: typeof import("../admin-layout.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../admin.logs.js")
type Info = GetInfo<{
file: "routes/admin.logs.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/admin-layout";
module: typeof import("../admin-layout.js");
}, {
id: "routes/admin.logs";
module: typeof import("../admin.logs.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../admin.users.$id.js")
type Info = GetInfo<{
file: "routes/admin.users.$id.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/admin-layout";
module: typeof import("../admin-layout.js");
}, {
id: "routes/admin.users.$id";
module: typeof import("../admin.users.$id.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../admin.users.new.js")
type Info = GetInfo<{
file: "routes/admin.users.new.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/admin-layout";
module: typeof import("../admin-layout.js");
}, {
id: "routes/admin.users.new";
module: typeof import("../admin.users.new.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../admin.users.js")
type Info = GetInfo<{
file: "routes/admin.users.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/admin-layout";
module: typeof import("../admin-layout.js");
}, {
id: "routes/admin.users";
module: typeof import("../admin.users.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.companies.$id.customers.js")
type Info = GetInfo<{
file: "routes/api.companies.$id.customers.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.companies.$id.customers";
module: typeof import("../api.companies.$id.customers.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.companies.$id.invoices.js")
type Info = GetInfo<{
file: "routes/api.companies.$id.invoices.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.companies.$id.invoices";
module: typeof import("../api.companies.$id.invoices.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.companies.$id.js")
type Info = GetInfo<{
file: "routes/api.companies.$id.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.companies.$id";
module: typeof import("../api.companies.$id.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.companies.js")
type Info = GetInfo<{
file: "routes/api.companies.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.companies";
module: typeof import("../api.companies.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.customers.$id.js")
type Info = GetInfo<{
file: "routes/api.customers.$id.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.customers.$id";
module: typeof import("../api.customers.$id.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.customers.js")
type Info = GetInfo<{
file: "routes/api.customers.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.customers";
module: typeof import("../api.customers.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.invoices.$id.pdf.js")
type Info = GetInfo<{
file: "routes/api.invoices.$id.pdf.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.invoices.$id.pdf";
module: typeof import("../api.invoices.$id.pdf.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.invoices.$id.js")
type Info = GetInfo<{
file: "routes/api.invoices.$id.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.invoices.$id";
module: typeof import("../api.invoices.$id.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.invoices.js")
type Info = GetInfo<{
file: "routes/api.invoices.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.invoices";
module: typeof import("../api.invoices.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.reports.js")
type Info = GetInfo<{
file: "routes/api.reports.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.reports";
module: typeof import("../api.reports.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.services.$id.js")
type Info = GetInfo<{
file: "routes/api.services.$id.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.services.$id";
module: typeof import("../api.services.$id.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../api.services.js")
type Info = GetInfo<{
file: "routes/api.services.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/api.services";
module: typeof import("../api.services.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../archiv.js")
type Info = GetInfo<{
file: "routes/archiv.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/archiv";
module: typeof import("../archiv.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.customers.js")
type Info = GetInfo<{
file: "routes/companies.$id.customers.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.customers";
module: typeof import("../companies.$id.customers.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.edit.js")
type Info = GetInfo<{
file: "routes/companies.$id.edit.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.edit";
module: typeof import("../companies.$id.edit.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.invoices.$invoiceId.edit.js")
type Info = GetInfo<{
file: "routes/companies.$id.invoices.$invoiceId.edit.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.invoices.$invoiceId.edit";
module: typeof import("../companies.$id.invoices.$invoiceId.edit.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.invoices.$invoiceId.js")
type Info = GetInfo<{
file: "routes/companies.$id.invoices.$invoiceId.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.invoices.$invoiceId";
module: typeof import("../companies.$id.invoices.$invoiceId.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.invoices.new.js")
type Info = GetInfo<{
file: "routes/companies.$id.invoices.new.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.invoices.new";
module: typeof import("../companies.$id.invoices.new.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.invoices.js")
type Info = GetInfo<{
file: "routes/companies.$id.invoices.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.invoices";
module: typeof import("../companies.$id.invoices.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.leistungen.js")
type Info = GetInfo<{
file: "routes/companies.$id.leistungen.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.leistungen";
module: typeof import("../companies.$id.leistungen.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.reports.js")
type Info = GetInfo<{
file: "routes/companies.$id.reports.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id.reports";
module: typeof import("../companies.$id.reports.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.$id.js")
type Info = GetInfo<{
file: "routes/companies.$id.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.$id";
module: typeof import("../companies.$id.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.new.js")
type Info = GetInfo<{
file: "routes/companies.new.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies.new";
module: typeof import("../companies.new.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../companies.js")
type Info = GetInfo<{
file: "routes/companies.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/companies";
module: typeof import("../companies.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../dashboard-layout.js")
type Info = GetInfo<{
file: "routes/dashboard-layout.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../home.js")
type Info = GetInfo<{
file: "routes/home.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/home";
module: typeof import("../home.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../login.js")
type Info = GetInfo<{
file: "routes/login.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/login";
module: typeof import("../login.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,62 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../logout.js")
type Info = GetInfo<{
file: "routes/logout.ts",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/logout";
module: typeof import("../logout.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
@@ -1,65 +0,0 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../settings.password.js")
type Info = GetInfo<{
file: "routes/settings.password.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/dashboard-layout";
module: typeof import("../dashboard-layout.js");
}, {
id: "routes/settings.password";
module: typeof import("../settings.password.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}
+53
View File
@@ -0,0 +1,53 @@
# Annas Rechnungsmanager Agent Guidance
## Development Commands
- Install: `npm install`
- Dev server: `npm run dev` (Vite on http://localhost:5173)
- Dev with DB: `npm run devfull` (starts docker-compose DB first)
- Typecheck: `npm run typecheck` (react-router typegen + tsc)
- Lint: `npm run lint` (eslint)
- Test: `npm run test` (vitest run)
- DB migrate: `npm run db:migrate` (prisma migrate dev)
- DB seed: `npm run db:seed` (loads demo data)
- DB studio: `npm run db:studio` (Prisma GUI)
- Admin setup: `npm run setup-admin` (sets ADMIN_PASSWORD env var or prompts)
- Reset password: `npm run reset-password -- --username <user> --password <pass>`
- Build: `npm run build`
- Start prod: `npm run start`
## Environment
- `.env` required: DATABASE_URL and AUTH_SECRET
- Dev: http://localhost:5173
- Docker: http://localhost:3000 (first start requires ADMIN_PASSWORD)
## Docker Deployment
- First start: `docker build -t annasrechnungsmanager:latest .` then `docker compose up -d` with ADMIN_PASSWORD set
- Subsequent: `docker build -t annasrechnungsmanager:latest .` then `docker compose up -d --no-deps app`
- Admin user created/updated on first start with ADMIN_PASSWORD
## Code Structure
- `app/routes/` - file-based routing (React Router v7)
- `app/routes/api.*` - REST API endpoints (loader/action)
- `app/lib/` - data access, Prisma, tax, utils
- `prisma/schema.prisma` - database schema
- `scripts/` - setup-admin, reset-password
## Conventions
- TypeScript strict; use unknown/guard checks for external data
- UI: Tailwind v4 + shadcn/ui
- Error handling: Remix redirect, json, badRequest
- API contracts in `app/routes/api.*` must remain backward compatible
- Document business logic for complex changes (taxes, invoice codes, UStG §14)
## Testing
- Vitest: `npm run test` (run), `npm run test:watch` (watch)
- No additional services required for unit tests
## graphify
This project has a graphify knowledge graph at graphify-out/.
Rules:
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current
+82
View File
@@ -0,0 +1,82 @@
# Annas Rechnungsmanager (CLAUDE onboarding)
## 1. Projektüberblick
Annas Rechnungsmanager ist ein Buchhaltungs- und Rechnungsverwaltungssystem für Steuerberater und Buchhalter mit Mandantenverwaltung. Funktionalitäten:
- Mandantenverwaltung (CRUD, Archiv, Papierkorb)
- Rechnungsverwaltung (Erstellen, PDF-/XML-Export, Zahlung eintragen)
- Kundenverwaltung (Stammdaten pro Mandant)
- Steuerberichte (USt-Voranmeldung, Monats-/Quartalsreport)
- Benutzerverwaltung mit Rollen (ADMIN, USER)
- Audit-Log (Aktionen + IP + Benutzer)
## 2. Tech Stack (Schlüsseltechnologien)
- `Node.js 22+`, `TypeScript`
- REMIX/React Router v7 (Server-/Client-CSS, SSR, file-based routing)
- `Prisma` + `MariaDB` (MySQL-kompatibel)
- Authentifizierung: cookie-basierte Sessions, `bcryptjs`
- UI: `Tailwind CSS v4`, `shadcn/ui`, Remix components
- PDF: `@react-pdf/renderer`
- Deployment: `Docker`, `docker-compose`, optional `k8s`
## 3. Repository-Architektur
- `app/` - Remiх/React-Router-Quellcode
- `components/` - shared UI und Domain-Komponenten (`company`, `invoice`, `layout`, `ui`)
- `lib/` - Datenbank, Logik, Helpers
- `routes/` - Datei-Routing-Endpunkte und APIs
- `session.server.ts` - Session/Auth-Handling
- `types/index.ts` - globale Typen
- `prisma/` - Schema, Migrationen, Seeder
- `scripts/` - CLI-Hilfs-Skripte (`setup-admin`, `reset-password`)
- `docker-compose.yml`, `Dockerfile`, `k8s.yml` - Deployment & Infrastruktur
## 4. Schlüsseldateien
- `app/root.tsx` - Root-Layout und Fehlergrenzen
- `app/entry.server.tsx` - Server-Entry (SSR)
- `app/routes/index.tsx` (`home`, `dashboard`, `login`, `settings`)
- `app/routes/api.*` - REST-API-Endpunkte für CRUD (companies, invoices, etc.)
- `app/lib/prisma.server.ts` - Prisma-Client-Initialisierung
- `app/lib/logger.server.ts`, `rate-limiter.server.ts` - Infrastruktur
- `prisma/schema.prisma` - Datenmodell
- `package.json` und `tsconfig.json` - Build / Lint / Types
## 5. Konventionen & Code-Richtlinien
- FS-basierte React Router v7 Routen
- Server-Endpunkte in `app/routes/api.*` als Remix-Loaders/Actions
- Mutationen und Datenzugriffe in `app/lib` (Prisma, Tax, utils)
- Typescript-sicher und null-safe; bevorzugt `unknown`/`guard`-Checks für externe Daten
- UI-Toolkit: shadcn-Komponenten + Tailwind Utility-Klassen
- Error-Handling mit Remix `redirect`, `json`, `badRequest`
## 6. Häufige Aufgaben und Workflows
- Lokale Entwicklung: `npm install`, `.env` konfigurieren, `npx prisma migrate deploy`, `npm run dev`
- DB initialisieren/seeden: `npm run db:seed`; `npm run db:migrate`
- Admin einrichten: `npm run setup-admin` (oder via Docker-Env `ADMIN_PASSWORD` beim Start)
- Passwort reset: `npm run reset-password`
- Automatische Migration beim Containerstart (Production)
## 7. Spezielle Hinweise für Claude
- Bevorzuge präzise Änderungen in bestehendem Code (letzte Routen und Libs).
- Halte Backward-Kompatibilität: bestehende API-Contracts in `api.*` sollen intakt bleiben.
- Dokumentiere bei komplexen Änderungen Business-Logik (Steuern, Rechnungscodes, UStG §14).
- Vollständige Test: `npm run typecheck`, ggf. `npm run lint` (falls eingerichtet).
## 8. Agent-Persona (optional)
- Rolle: Full-stack Remix / TypeScript-Fachkraft für deutsche Rechnungssoftware
- Fokus: Feature-Implementierung im Domain-Kontext (Invoices, Reports, Clients)
- Toolpräferenzen: `read_file`, `grep_search`, `replace_string_in_file` und `run_in_terminal` für Verifikation
- Vermeide: ungetestete massive Refactorings ohne vorhandenen Abdeckungsstatus
## 9. Weiteres
- `CLAUDE.md` wird als Projekt-spezifisches Onboarding für ChatGPT/Claude-Agenten genutzt.
- Für Dev-Workflows und PR-Beschreibungen, bitte auf `README.md` und `package.json` verweisen.
+10 -2
View File
@@ -3,7 +3,11 @@ FROM node:alpine AS builder
WORKDIR /app
RUN apk add --no-cache openssl
RUN if command -v apk >/dev/null 2>&1; then \
apk add --no-cache openssl; \
elif command -v apt-get >/dev/null 2>&1; then \
apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/*; \
fi
COPY package*.json ./
RUN npm ci
@@ -25,7 +29,11 @@ WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache openssl
RUN if command -v apk >/dev/null 2>&1; then \
apk add --no-cache openssl; \
elif command -v apt-get >/dev/null 2>&1; then \
apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/*; \
fi
COPY package*.json ./
RUN npm ci --omit=dev
+181
View File
@@ -0,0 +1,181 @@
# Verbesserungen implementiert Annas Rechnungsmanager
Datum: 15. April 2026
Implementiert durch: Copilot
Status: ✅ Abgeschlossen
---
## 🔴 Kritische Sicherheitsfixes
### 1. **Server-seitige Betragsvalidierung** ✅
**Dateien:** `app/routes/api.invoices.ts`, `app/routes/api.invoices.$id.ts`
**Problem:** Client-Beträge (`netTotal`, `taxTotal`, `grossTotal`) wurden direkt in die DB gespeichert.
**Lösung:**
- Alle Beträge werden jetzt **serverseitig neuberechnet** aus Qty × UnitPrice
- Verwendung der verifizierten `calcItemAmounts()` und `calcInvoiceTotals()` Funktionen
- `kleinunternehmer`-Flag wird automatisch von der Firma übernommen (fallback zu Client-Wert)
- Transaktionale Konsistenz erhalten
### 2. **Vollständiges Audit-Logging** ✅
**Dateien:** `app/lib/logger.server.ts`, `app/routes/api.companies.ts`, `app/routes/api.companies.$id.ts`, `app/routes/api.customers.ts`, `app/routes/api.customers.$id.ts`
**Probleme:**
- `LogAction`-Typ fehlten: `CHANGE_PASSWORD`, `CREATE_INVOICE`, `UPDATE_INVOICE`, etc.
- Viele API-Operationen waren nicht geloggt (CREATE_COMPANY, CREATE_CUSTOMER, etc.)
**Lösung:**
- Typ um 11 neue Actions erweitert
- Logging hinzugefügt für:
- ✅ CREATE_COMPANY, UPDATE_COMPANY, DELETE_COMPANY, ARCHIVE_COMPANY
- ✅ CREATE_INVOICE, UPDATE_INVOICE
- ✅ CREATE_CUSTOMER, UPDATE_CUSTOMER, DELETE_CUSTOMER
- Strukturelle Konsistenz: alle CRUD-Operationen jetzt logged
### 3. **Verbesserte Zod-Validierung** ✅
**Datei:** `app/lib/schemas.ts` (NEW - 220 Zeilen)
**Änderungen:**
- Zentrale Datenbank für alle Validierungsschemas
- Custom Validatoren:
- `currencySchema`: nonnegative, max 2 dezimalstellen
- `taxRateSchema`: nur 0, 7, 19
- `ibanSchema`: Format-Validierung DE/AT/CH
- `taxIdSchema`: 11-stellige deutsche Steuernummer
- `vatIdSchema`: EU-USt-ID mit Länderprefix
- Invoice/Company/Customer Schemas mit feldspezifischen Maxlängen
- Fehler auf Deutsch
**Integration:**
- `api.invoices.ts``invoiceSchema`, `invoiceUpdateSchema`
- `api.invoices.$id.ts``invoiceStatusSchema` für PATCH
- `api.companies.ts`, `api.companies.$id.ts``companySchema`, `companyUpdateSchema`
- `api.customers.ts`, `api.customers.$id.ts``customerSchema`, `customerUpdateSchema`
**Vorteil:** Single source of truth für Validierung, konsistente Fehlermeldungen, leicht änderbar
---
## 🟠 Schema & Datenmodell
### 4. **Missing `vatId` Field** ✅
**Datei:** `app/routes/api.companies.ts`
- Feld war im Prisma-Modell definiert, aber nicht im Create-Schema
- Jetzt können Mandanten beim Anlegen die USt-IdNr. setzen
### 5. **DB-Indizes für Performance** ✅
**Datei:** `prisma/schema.prisma` + Migration `20260415192953_add_indices`
Hinzugefügt:
```prisma
// Invoice Indices
@@index([status]) // für Filterung nach Status (DRAFT, PAID, etc.)
@@index([dueDate]) // für Mahnwesen und Reports
@@index([deletedAt]) // für Cleanup-Scheduler
@@index([customerId]) // für Customer-Dashboards (via FK)
// Customer Index
@@index([companyId]) // für Company-Dashboard (via FK)
```
**Vorteil:** Queries mit WHERE/ORDER BY auf diese Felder sind O(log n) statt O(n).
**Status:** ✅ Migration erfolgreich angewendet
### 6. **Konsistente Schema-Definition** ✅
**Dateien:** `api.companies.ts`, `api.companies.$id.ts`
- Beide Dateien hatten leicht unterschiedliche `companySchema` Definitionen
- Jetzt identisch und vollständig
- Fehler-Anfälligkeit reduziert
---
## 🟡 Code Quality
### 7. **Duplizierte Config-Files entfernt** ✅
Gelöscht:
- `react-router.config.js` (behalten: `.ts`)
- `vite.config.js` (behalten: `.ts`)
- `postcss.config.js` (behalten: `.ts`)
**Warum:** Redundanz verwirrt Entwickler und kann zu Inkonsistenzen führen.
---
## 📋 Nicht implementiert (nachgelagert)
### Rate-Limiter Multi-Instance
- Benötigt Redis für verteilte Szenarien
- Aktuell `RateLimiterMemory` ist ausreichend für Single-Pod
- **TODO:** Bei Kubernetes-Deployment mit Redis ergänzen
### User-DB-Lookup in `requireUser()`
- Session prüft aktuell nur Cookie (TTL 4h)
- Könnte gelöschte/gesperrte User noch akzeptieren
- **TODO:** Optional mit kurzem TTL-Cache implementieren
### Test-Framework (vitest)
- Für Steuerberechnung (`tax.ts`) kritisch
- **TODO:** Unit-Tests für alle Tax-Szenarios hinzufügen
---
## ✅ Nächste Schritte
1. **DB-Migration deployen:**
```bash
npm run db:migrate
```
2. **Build testen:**
```bash
npm run build
```
3. **Staging testen:**
- Invoice mit verschiedenen Steuer-Sätzen erstellen
- Prüfen: Beträge werden korrekt berechnet
- Audit-Log prüfen: Alle Aktionen geloggt
4. **Rollout:** Deployment mit neuer Prisma-Migration
---
## 📊 Änderungsübersicht
| Kategorie | Dateien | Änderungen |
|-----------|---------|-----------|
| Critical | 8 | Betragsvalidierung + Audit-Logging |
| Schema | 6 | Zod-Validierung + vatId + Indizes |
| Quality | 3 | Config-Cleanup |
| **Total** | **≥15 Dateien** | **Durchgehend sicherer** |
---
## 🔒 Sicherheitsauswirkungen
| Issue | Risiko | Fix | Impact |
|-------|--------|-----|--------|
| Beträge manipulierbar | 🔴 Kritisch | Server-Recalc | ✅ Eliminiert |
| Lückenhaftes Audit-Log | 🔴 Hoch | Logging erweitert | ✅ Vollständig |
| Fehlende Validierung | 🟠 Mittel | Zod Max-Längen | ✅ Reduziert |
---
## Backward Compatibility
**Vollständig erhalten:**
- API-Endpunkte ändern Signatur nicht
- Neue Log-Actions sind addativ (non-breaking)
- Zod-Validierung ist nur strikter (lehnt invalide Requests ab)
- Alten Datenbankeinträge funktionieren mit Indizes genauso
---
Entwickler können sofort mit der Implementierung starten. Alle kritischen Sicherheitslücken sind behoben.
+95
View File
@@ -0,0 +1,95 @@
/**
* INTEGRATION EXAMPLE: How to use error logging in your existing routes
* This shows the new error-logger.server in real-world usage
*/
// Before (old code - minimal logging):
/*
export async function action({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json();
const parsed = customerSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
try {
const customer = await prisma.customer.create({ data: parsed.data });
await log({ userId: user.id, action: "CREATE_CUSTOMER", entity: "Customer", entityId: customer.id, request });
return Response.json(customer, { status: 201 });
} catch (error) {
console.error(error); // ❌ Not helpful for debugging
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
*/
// After (with comprehensive error logging):
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { log } from "@/lib/logger.server";
import { logApiError } from "@/lib/error-logger.server";
import { customerSchema } from "@/lib/schemas";
export async function action({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
try {
const body = await request.json();
const parsed = customerSchema.safeParse(body);
if (!parsed.success) {
return Response.json({ error: parsed.error.issues }, { status: 400 });
}
const customer = await prisma.customer.create({ data: parsed.data });
await log({
userId: user.id,
action: "CREATE_CUSTOMER",
entity: "Customer",
entityId: customer.id,
request,
});
return Response.json(customer, { status: 201 });
} catch (error) {
// ✅ Now with full context: stack trace, request details, metadata
logApiError(error, {
request,
endpoint: "/api/customers",
userId: user.id,
statusCode: 500,
metadata: {
method: request.method,
},
});
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
// ============================================================================
// WHAT YOU'LL SEE IN THE SERVER LOGS NOW:
// ============================================================================
/*
================================================================================
[API_ERROR] | 2026-05-02T22:15:45.789Z | duplicate entry for unique field 'email'
POST /api/customers | user: user-abc123 | ip: 192.168.1.100 | endpoint: /api/customers | statusCode: 500
Error: Customer with email 'john@example.com' already exists
at Object.create (file:///app/lib/customer.server.ts:25:13)
at action (file:///app/routes/api.customers.ts:30:7)
at processRequest (file:///app/routes/_middleware.ts:12:3)
Metadata: {
"method": "POST",
"endpoint": "/api/customers"
}
================================================================================
✓ You can now see:
- Exact error message with context
- HTTP method & endpoint
- User ID & IP address
- Stack trace for debugging
- Custom metadata
- Timestamp
*/
+36
View File
@@ -105,6 +105,42 @@ Migrationen werden beim Start automatisch angewendet. `ADMIN_PASSWORD` ist nicht
---
## CI/CD (Gitea Actions)
Die Pipeline liegt in `.gitea/workflows/build.yml` und baut/pusht bei jedem Push auf `main` ein Docker-Image.
### Voraussetzungen
- Secret `REGISTRY_TOKEN` ist im Repository gesetzt
- Der Token hat Berechtigung zum Push in die Gitea Container Registry
- Der Registry-Host ist vom Runner aus erreichbar
### Aktuelle Registry-Konfiguration
Die Pipeline nutzt aktuell:
- `REGISTRY=git.henryathome.home64.de`
- Image-Name aus `${{ gitea.repository }}`
- Tags: kurzer Commit-SHA + `latest` (auf Default-Branch)
### Häufiger Fehler: `i/o timeout` beim Push
Fehlerbild (vereinfacht):
`failed to push ... Head "https://.../v2/.../blobs/...": dial tcp ...:443: i/o timeout`
Typische Ursache:
- Die Registry-Adresse ist intern (z. B. `*.svc.cluster.local`) und vom CI-Runner nicht erreichbar.
Lösung:
- In `.gitea/workflows/build.yml` einen extern erreichbaren Host bei `REGISTRY` eintragen.
- Sicherstellen, dass DNS, Firewall und Portfreigaben vom Runner zur Registry passen.
- Prüfen, ob HTTPS korrekt terminiert ist (Zertifikat/Reverse Proxy), da Buildx standardmäßig per HTTPS pusht.
---
## Admin-Benutzer & Recovery
### Admin-Passwort setzen (im laufenden Container)
+10
View File
@@ -1,5 +1,15 @@
@import "tailwindcss";
/* Pfeile bei number-inputs ausblenden */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
:root {
--background: #f8fafc;
--foreground: #0f172a;
+222 -21
View File
@@ -4,6 +4,8 @@ import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { debugLog, handleApiError } from "@/lib/client-validation";
import { useState, useEffect } from "react";
const schema = z.object({
name: z.string().min(1, "Name ist erforderlich"),
@@ -32,37 +34,193 @@ interface CompanyFormProps {
submitLabel?: string;
}
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
function Field({
label,
error,
tooltip,
required = false,
children
}: {
label: string;
error?: string;
tooltip?: string;
required?: boolean;
children: React.ReactNode
}) {
const [showRequiredTooltip, setShowRequiredTooltip] = useState(false);
// Debug: log errors to console
if (error) {
console.log(`[Field Error] ${label}: ${error}`);
}
return (
<div className="space-y-1.5">
<Label>{label}</Label>
<Label className="flex items-center gap-1">
{label}
{required && (
<span className="inline-flex items-center group relative">
<span className="text-red-500 font-bold text-lg leading-none">*</span>
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-red-600 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
>
Erforderliches Feld
</div>
</span>
)}
</Label>
<div className="relative">
<div className={required && !error ? "relative" : ""}>
{children}
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
{/* Show error tooltip ONLY when there's an error */}
{error && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-700 flex gap-2">
<svg className="h-4 w-4 text-red-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<div>
<p className="font-medium">{error}</p>
{tooltip && <p className="text-red-600 mt-0.5">{tooltip}</p>}
</div>
</div>
)}
</div>
</div>
);
}
export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern" }: CompanyFormProps) {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
const [apiError, setApiError] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const { register, handleSubmit, formState: { errors, isSubmitting, isValid }, watch, trigger } = useForm<FormData>({
resolver: zodResolver(schema),
mode: "onBlur", // Validate when user leaves field
defaultValues: { country: "DE", invoicePrefix: "RE", kleinunternehmer: false, ...defaultValues },
});
// Debug: log all errors
useEffect(() => {
if (Object.keys(errors).length > 0) {
console.log("[Form Errors]", errors);
}
}, [errors]);
// Watch form data for debug logging
const formData = watch();
const handleFormSubmit = async (data: FormData) => {
// Trigger validation to check if form is actually valid
const isFormValid = await trigger();
if (!isFormValid) {
// Build error message from validation errors
const errorFields: string[] = [];
if (errors.name) errorFields.push("Firmenname");
if (errors.address) errorFields.push("Adresse");
if (errors.zip) errorFields.push("Postleitzahl");
if (errors.city) errorFields.push("Ort/Stadt");
if (errors.email) errorFields.push("E-Mail");
if (errors.taxId) errorFields.push("Steuernummer");
if (errors.vatId) errorFields.push("USt-IdNr.");
if (errors.bankIban) errorFields.push("IBAN");
if (errors.bankBic) errorFields.push("BIC");
const fieldList = errorFields.length > 0
? errorFields.join(", ")
: "Bitte überprüfen Sie die rot gekennzeichneten Felder";
const message = `Folgende Felder sind erforderlich oder falsch: ${fieldList}`;
setValidationError(message);
debugLog("warning", "Form validation failed", { errors: errorFields });
return;
}
try {
setApiError(null);
setValidationError(null);
debugLog("info", "Submitting form", data);
await onSubmit(data);
debugLog("success", "Form submitted successfully");
} catch (error) {
debugLog("error", "Form submission failed", error);
handleApiError(error, "/api/companies");
// Extract error message for user
if (error instanceof Response) {
try {
const json = await error.clone().json();
const messages = json.error?.map?.((e: any) => e.message)?.join(", ") ||
json.message ||
"Ein Fehler ist aufgetreten";
setApiError(messages);
} catch {
setApiError(`HTTP ${error.status}: ${error.statusText}`);
}
} else if (error instanceof Error) {
setApiError(error.message);
} else {
setApiError("Ein unbekannter Fehler ist aufgetreten");
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Required fields info banner */}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm font-medium text-blue-900"> Erforderliche Felder</p>
<p className="text-sm text-blue-800 mt-1">Folgende Felder sind erforderlich: <strong>Firmenname, Adresse, Postleitzahl, Ort</strong></p>
</div>
{/* Validation error banner */}
{validationError && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm font-medium text-yellow-900"> Eingabefehler</p>
<p className="text-sm text-yellow-800 mt-1">{validationError}</p>
</div>
)}
{/* API error banner */}
{apiError && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm font-medium text-red-800"> Fehler</p>
<p className="text-sm text-red-700 mt-1">{apiError}</p>
</div>
)}
<div>
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Stammdaten</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="Firmenname *" error={errors.name?.message}>
<Field
label="Firmenname"
required={true}
error={errors.name?.message}
tooltip="Name des Unternehmens oder der Geschäftseinheit"
>
<Input {...register("name")} placeholder="Muster GmbH" />
</Field>
<Field label="Rechtsform" error={errors.legalForm?.message}>
<Field
label="Rechtsform"
error={errors.legalForm?.message}
tooltip="z.B. GmbH, AG, UG, Einzelunternehmen"
>
<Input {...register("legalForm")} placeholder="GmbH, AG, UG..." />
</Field>
<Field label="Steuernummer" error={errors.taxId?.message}>
<Field
label="Steuernummer"
error={errors.taxId?.message}
tooltip="10-stellige deutsche Steuernummer (z.B. 123/456/78901)"
>
<Input {...register("taxId")} placeholder="123/456/78901" />
</Field>
<Field label="USt-IdNr." error={errors.vatId?.message}>
<Field
label="USt-IdNr."
error={errors.vatId?.message}
tooltip="Umsatzsteuer-Identifikationsnummer (z.B. DE123456789)"
>
<Input {...register("vatId")} placeholder="DE123456789" />
</Field>
</div>
@@ -72,14 +230,29 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Anschrift</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<Field label="Straße & Hausnummer *" error={errors.address?.message}>
<Field
label="Straße & Hausnummer"
required={true}
error={errors.address?.message}
tooltip="Vollständige Adresse mit Straße und Hausnummer"
>
<Input {...register("address")} placeholder="Musterstraße 1" />
</Field>
</div>
<Field label="PLZ *" error={errors.zip?.message}>
<Field
label="PLZ"
required={true}
error={errors.zip?.message}
tooltip="Deutsche Postleitzahl (5-stellig)"
>
<Input {...register("zip")} placeholder="10115" />
</Field>
<Field label="Ort *" error={errors.city?.message}>
<Field
label="Ort"
required={true}
error={errors.city?.message}
tooltip="Stadt oder Gemeinde"
>
<Input {...register("city")} placeholder="Berlin" />
</Field>
</div>
@@ -88,13 +261,25 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
<div>
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Kontakt</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="E-Mail" error={errors.email?.message}>
<Field
label="E-Mail"
error={errors.email?.message}
tooltip="Kontakt-E-Mail-Adresse (optional)"
>
<Input {...register("email")} type="email" placeholder="info@firma.de" />
</Field>
<Field label="Telefon" error={errors.phone?.message}>
<Field
label="Telefon"
error={errors.phone?.message}
tooltip="Telefonnummer mit Landesvorwahl (optional)"
>
<Input {...register("phone")} placeholder="+49 30 12345678" />
</Field>
<Field label="Website" error={errors.website?.message}>
<Field
label="Website"
error={errors.website?.message}
tooltip="URL der Unternehmenswebseite (optional)"
>
<Input {...register("website")} placeholder="https://firma.de" />
</Field>
</div>
@@ -104,14 +289,26 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Bankverbindung</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<Field label="IBAN" error={errors.bankIban?.message}>
<Field
label="IBAN"
error={errors.bankIban?.message}
tooltip="Internationale Kontonummer (z.B. DE89 3704 0044...) (optional)"
>
<Input {...register("bankIban")} placeholder="DE89 3704 0044 0532 0130 00" />
</Field>
</div>
<Field label="BIC" error={errors.bankBic?.message}>
<Field
label="BIC"
error={errors.bankBic?.message}
tooltip="Bank Identifier Code (z.B. COBADEFFXXX) (optional)"
>
<Input {...register("bankBic")} placeholder="COBADEFFXXX" />
</Field>
<Field label="Kreditinstitut" error={errors.bankName?.message}>
<Field
label="Kreditinstitut"
error={errors.bankName?.message}
tooltip="Name der Bank (optional)"
>
<Input {...register("bankName")} placeholder="Commerzbank" />
</Field>
</div>
@@ -120,7 +317,11 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
<div>
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Rechnungseinstellungen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="Rechnungsnummern-Präfix" error={errors.invoicePrefix?.message}>
<Field
label="Rechnungsnummern-Präfix"
error={errors.invoicePrefix?.message}
tooltip="Präfix für Rechnungsnummern (z.B. RE oder INV)"
>
<Input {...register("invoicePrefix")} placeholder="RE" />
</Field>
</div>
@@ -139,8 +340,8 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
</div>
</div>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSubmitting}>
<div className="flex justify-end gap-3 pt-2">
<Button type="submit" disabled={isSubmitting || !isValid}>
{isSubmitting ? "Speichern..." : submitLabel}
</Button>
</div>
+116
View File
@@ -0,0 +1,116 @@
/**
* Debug panel component - shows in development and when debug mode is enabled
* Access via browser console: setDebugMode(true) or localStorage.setItem('DEBUG_MODE', 'true')
*/
import { useEffect, useState } from "react";
import { isDebugMode, setDebugMode } from "@/lib/client-validation";
export function DebugPanel() {
const [isOpen, setIsOpen] = useState(false);
const [debugEnabled, setDebugEnabled] = useState(false);
useEffect(() => {
setDebugEnabled(isDebugMode());
}, []);
const toggleDebug = () => {
const newState = !debugEnabled;
setDebugMode(newState);
setDebugEnabled(newState);
};
// Only show in development
if (process.env.NODE_ENV !== "development") {
return null;
}
return (
<>
{/* Floating button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full font-bold text-white text-lg transition-all ${
debugEnabled
? "bg-blue-600 hover:bg-blue-700"
: "bg-gray-400 hover:bg-gray-500"
}`}
title={debugEnabled ? "Debug Mode: ON" : "Debug Mode: OFF"}
>
🐛
</button>
{/* Debug panel */}
{isOpen && (
<div className="fixed bottom-20 right-4 z-40 bg-gray-900 text-white rounded-lg shadow-2xl p-4 w-80 max-h-96 overflow-auto">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-bold text-lg">Debug Mode</h3>
<button
onClick={() => setIsOpen(false)}
className="text-gray-400 hover:text-white"
>
</button>
</div>
<div className="border-t border-gray-700 pt-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={debugEnabled}
onChange={toggleDebug}
className="w-4 h-4"
/>
<span className="text-sm">
{debugEnabled ? "✅ Enabled" : "⭕ Disabled"}
</span>
</label>
</div>
<div className="bg-gray-800 p-3 rounded text-xs space-y-1">
<p className="text-gray-400">Browser Console Commands:</p>
<code className="block text-blue-400">
setDebugMode(true)
</code>
<code className="block text-blue-400">
localStorage.setItem('DEBUG_MODE', 'true')
</code>
<code className="block text-green-400">
isDebugMode()
</code>
</div>
<div className="bg-gray-800 p-3 rounded text-xs">
<p className="text-gray-400 mb-1">Current State:</p>
<p className="text-yellow-300">
DEBUG: <strong>{debugEnabled ? "ON" : "OFF"}</strong>
</p>
<p className="text-yellow-300">
ENV: <strong>{process.env.NODE_ENV}</strong>
</p>
</div>
<button
onClick={() => {
if (debugEnabled) {
console.log("🔍 Debug Info:");
console.log({
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
});
} else {
alert("Enable Debug Mode first!");
}
}}
className="w-full bg-blue-600 hover:bg-blue-700 px-3 py-2 rounded text-sm font-medium"
>
Log Debug Info
</button>
</div>
</div>
)}
</>
);
}
+4 -13
View File
@@ -8,7 +8,6 @@ import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { calcItemAmounts, calcItemAmountsKleinunternehmer, calcInvoiceTotals, formatCurrency, TAX_RATES } from "@/lib/tax";
import { Plus, Trash2 } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface Customer {
id: string;
@@ -276,10 +275,9 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultValues, def
</div>
<div className="border border-gray-200 rounded-xl">
<div className={`grid gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600 rounded-t-xl ${kleinunternehmer ? "grid-cols-11" : "grid-cols-12"}`}>
<div className={`grid gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600 rounded-t-xl ${kleinunternehmer ? "grid-cols-10" : "grid-cols-11"}`}>
<div className="col-span-4">Beschreibung</div>
<div className="col-span-1">Menge</div>
<div className="col-span-1">Einh.</div>
<div className="col-span-2">{kleinunternehmer ? "Einzelpreis (brutto)" : "Einzelpreis"}</div>
{!kleinunternehmer && <div className="col-span-1">MwSt.</div>}
<div className="col-span-2 text-right">Gesamt (brutto)</div>
@@ -287,7 +285,7 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultValues, def
</div>
{fields.map((field, index) => (
<div key={field.id} className={`grid gap-2 px-3 py-2.5 border-b border-gray-100 last:border-0 items-center ${kleinunternehmer ? "grid-cols-11" : "grid-cols-12"}`}>
<div key={field.id} className={`grid gap-2 px-3 py-2.5 border-b border-gray-100 last:border-0 items-center ${kleinunternehmer ? "grid-cols-10" : "grid-cols-11"}`}>
<div className="col-span-4 relative">
{(() => {
const descValue = watchedItems[index]?.description ?? "";
@@ -302,7 +300,7 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultValues, def
value={descValue}
onChange={(e) => setValue(`items.${index}.description`, e.target.value)}
onFocus={() => setOpenDropdown(index)}
onBlur={() => setOpenDropdown(null)}
onBlur={() => setTimeout(() => setOpenDropdown(null), 100)}
placeholder="Leistungsbeschreibung"
className="text-sm"
autoComplete="off"
@@ -316,7 +314,7 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultValues, def
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex flex-col"
onMouseDown={(e) => {
e.preventDefault();
setValue(`items.${index}.description`, s.description ?? s.name);
setValue(`items.${index}.description`, s.name);
setValue(`items.${index}.unit`, s.unit ?? "Stück");
setValue(`items.${index}.unitPrice`, String(s.unitPrice));
setValue(`items.${index}.taxRate`, String(s.taxRate));
@@ -341,13 +339,6 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultValues, def
onBlur={() => recalcItem(index)}
/>
</div>
<div className="col-span-1">
<Input
{...register(`items.${index}.unit`)}
placeholder="Stück"
className="text-sm"
/>
</div>
<div className="col-span-2">
<Input
{...register(`items.${index}.unitPrice`)}
+1 -4
View File
@@ -112,8 +112,7 @@ const styles = StyleSheet.create({
col_pos: { width: "5%" },
col_desc: { width: "40%" },
col_qty: { width: "10%", textAlign: "right" },
col_unit: { width: "8%", textAlign: "center" },
col_price: { width: "14%", textAlign: "right" },
col_price: { width: "22%", textAlign: "right" },
col_tax: { width: "8%", textAlign: "center" },
col_total: { width: "15%", textAlign: "right" },
totalsSection: {
@@ -324,7 +323,6 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
<Text style={{ ...styles.tableHeaderText, ...styles.col_pos }}>#</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_desc }}>Beschreibung</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_qty }}>Menge</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_unit }}>Einh.</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_price }}>
{invoice.kleinunternehmer ? "EP (brutto)" : "EP (netto)"}
</Text>
@@ -339,7 +337,6 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
<Text style={{ ...styles.col_pos, fontSize: 9 }}>{item.position}</Text>
<Text style={{ ...styles.col_desc, fontSize: 9 }}>{item.description}</Text>
<Text style={{ ...styles.col_qty, fontSize: 9 }}>{n(item.quantity)}</Text>
<Text style={{ ...styles.col_unit, fontSize: 9 }}>{item.unit ?? ""}</Text>
<Text style={{ ...styles.col_price, fontSize: 9 }}>{formatMoney(n(item.unitPrice))}</Text>
{!invoice.kleinunternehmer && (
<Text style={{ ...styles.col_tax, fontSize: 9 }}>{n(item.taxRate)}%</Text>
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+19 -2
View File
@@ -1,4 +1,6 @@
import { startCleanupScheduler } from "@/lib/cleanup.server";
import { startCleanupScheduler } from "./lib/cleanup.server";
import { initializeDatabase } from "./lib/db-init.server";
import { logStartupError, logError } from "./lib/error-logger.server";
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
@@ -8,6 +10,12 @@ import { renderToPipeableStream } from "react-dom/server";
startCleanupScheduler();
// Initialize database: run migrations on startup
initializeDatabase().catch((error) => {
logStartupError(error);
process.exit(1);
});
const ABORT_DELAY = 5_000;
export default function handleRequest(
@@ -38,11 +46,20 @@ export default function handleRequest(
pipe(body);
},
onShellError(error: unknown) {
logError("SHELL_ERROR", error, {
request,
route: new URL(request.url).pathname,
});
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
if (shellRendered) console.error(error);
if (shellRendered) {
logError("RENDER_ERROR", error, {
request,
route: new URL(request.url).pathname,
});
}
},
}
);
+136
View File
@@ -0,0 +1,136 @@
/**
* Error Logging Usage Examples
*
* Use these patterns in your routes to get better error debugging
*/
// ============================================================================
// PATTERN 1: In a loader (data fetching)
// ============================================================================
import { json, type LoaderFunction } from "react-router";
import { logRouteError } from "@/lib/error-logger.server";
import prisma from "@/lib/prisma.server";
export const loader: LoaderFunction = async ({ request, params }) => {
try {
const company = await prisma.company.findUnique({
where: { id: params.id },
});
if (!company) {
throw new Error(`Company not found: ${params.id}`);
}
return json({ company });
} catch (error) {
logRouteError(error, {
request,
route: request.url,
userId: params.userId, // if available
metadata: {
companyId: params.id,
},
});
throw error; // Re-throw to let React Router handle it
}
};
// ============================================================================
// PATTERN 2: In an action (mutation/form submission)
// ============================================================================
import { logActionError } from "@/lib/error-logger.server";
export const action: LoaderFunction = async ({ request, params }) => {
try {
const formData = await request.formData();
const result = await prisma.company.update({
where: { id: params.id },
data: { name: formData.get("name") },
});
return json({ success: true, result });
} catch (error) {
logActionError(error, {
request,
action: "UPDATE_COMPANY",
metadata: {
companyId: params.id,
method: request.method,
},
});
throw error;
}
};
// ============================================================================
// PATTERN 3: In an API route (POST/PUT/DELETE)
// ============================================================================
import { logApiError } from "@/lib/error-logger.server";
export const action: LoaderFunction = async ({ request }) => {
try {
const data = await request.json();
// Validate
if (!data.name) {
return json(
{ error: "Name is required" },
{ status: 400 }
);
}
// Process
const result = await prisma.company.create({ data });
return json({ success: true, result }, { status: 201 });
} catch (error) {
logApiError(error, {
request,
endpoint: "/api/companies",
statusCode: 500,
metadata: {
method: request.method,
},
});
return json(
{ error: "Internal server error" },
{ status: 500 }
);
}
};
// ============================================================================
// PATTERN 4: Database operations with error context
// ============================================================================
import { logDatabaseError } from "@/lib/error-logger.server";
async function fetchCompanyWithUsers(companyId: string) {
try {
return await prisma.company.findUnique({
where: { id: companyId },
include: { users: true },
});
} catch (error) {
logDatabaseError(error, "company.findUnique", {
metadata: { companyId },
});
throw error;
}
}
// ============================================================================
// OUTPUT EXAMPLE (Server Console)
// ============================================================================
/*
================================================================================
[ROUTE_ERROR] | 2026-05-02T22:10:30.123Z | Company not found | route: /companies/123 | GET /companies/123 | user: user-id-456
Stack at Object.loader (file:///app/routes/companies.$id.tsx:12:13)
at process._tickCallback (internal/timers.ts:203:26)
Metadata: {
"companyId": "123"
}
================================================================================
*/
+67
View File
@@ -0,0 +1,67 @@
/**
* Lineare Abschreibung (AfA) nach §7 EStG
* Alle Geldwerte in Euro, 2 Dezimalstellen.
*/
export interface AnlagegutRaw {
anschaffungskosten: number;
nutzungsdauerJahre: number;
restwert: number;
anschaffungsdatum: string; // ISO-Datumsstring
aktiv: boolean;
}
/** Volle Jahres-AfA */
export function jahresAfa(ak: number, restwert: number, nd: number): number {
return Math.round(((ak - restwert) / nd) * 100) / 100;
}
/** Pro-rata AfA im Anschaffungsjahr: verbleibende Monate (inkl. Anschaffungsmonat) / 12 */
export function erwerbsjahrAfa(ak: number, restwert: number, nd: number, datum: Date): number {
const verbleibendeMonathe = 12 - datum.getMonth(); // getMonth() = 0-basiert, Jan=0
return Math.round((jahresAfa(ak, restwert, nd) * verbleibendeMonathe) / 12 * 100) / 100;
}
/** AfA für ein bestimmtes Kalenderjahr (0 wenn nicht erworben oder vollständig abgeschrieben) */
export function afaFuerJahr(asset: AnlagegutRaw, year: number): number {
const acqDate = new Date(asset.anschaffungsdatum);
const acqYear = acqDate.getFullYear();
const lastDepYear = acqYear + asset.nutzungsdauerJahre - 1;
if (year < acqYear || year > lastDepYear) return 0;
const ak = asset.anschaffungskosten;
const rv = asset.restwert;
const nd = asset.nutzungsdauerJahre;
return year === acqYear
? erwerbsjahrAfa(ak, rv, nd, acqDate)
: jahresAfa(ak, rv, nd);
}
/** Kumulierte AfA vom Anschaffungsjahr bis inkl. gegebenem Jahr */
export function kumulierteAfa(asset: AnlagegutRaw, bisJahr: number): number {
const acqYear = new Date(asset.anschaffungsdatum).getFullYear();
let total = 0;
for (let y = acqYear; y <= bisJahr; y++) {
total += afaFuerJahr(asset, y);
}
return Math.round(total * 100) / 100;
}
/** Buchwert zum 31.12. des gegebenen Jahres (Minimum: Restwert) */
export function buchwert(asset: AnlagegutRaw, year: number): number {
const acqYear = new Date(asset.anschaffungsdatum).getFullYear();
if (year < acqYear) return asset.anschaffungskosten;
const bw = asset.anschaffungskosten - kumulierteAfa(asset, year);
return Math.max(Math.round(bw * 100) / 100, asset.restwert);
}
/** Anzeige-Status eines Anlageguts */
export function assetStatus(asset: AnlagegutRaw, currentYear: number): "aktiv" | "vollständig abgeschrieben" | "inaktiv" {
if (!asset.aktiv) return "inaktiv";
const acqYear = new Date(asset.anschaffungsdatum).getFullYear();
const lastDepYear = acqYear + asset.nutzungsdauerJahre - 1;
if (currentYear > lastDepYear) return "vollständig abgeschrieben";
return "aktiv";
}
+41
View File
@@ -0,0 +1,41 @@
export const AUSGABE_KATEGORIEN = [
"WAREN_ROHSTOFFE",
"GERINGWERTIGE_WIRTSCHAFTSGUETER",
"ABSCHREIBUNGEN",
"MIETE",
"STROM_WASSER",
"TELEKOMMUNIKATION",
"FORTBILDUNG_MESSEN",
"BEITRAEGE",
"VERSICHERUNGEN",
"WERBEKOSTEN",
"ZINSEN",
"REISEKOSTEN",
"REPARATUREN_INSTANDHALTUNG",
"BUEROBEDARF",
"REPRAESENTATIONSKOSTEN",
"SONSTIGER_BETRIEBSBEDARF",
"NEBENKOSTEN_GELDVERKEHR",
] as const;
export type AusgabeKategorieKey = typeof AUSGABE_KATEGORIEN[number];
export const KATEGORIE_LABELS: Record<AusgabeKategorieKey, string> = {
WAREN_ROHSTOFFE: "Waren, Rohstoffe, Hilfsstoffe",
GERINGWERTIGE_WIRTSCHAFTSGUETER: "Geringwertige Wirtschaftsgüter",
ABSCHREIBUNGEN: "Abschreibungen",
MIETE: "Miete",
STROM_WASSER: "Strom, Wasser",
TELEKOMMUNIKATION: "Telekommunikationskosten",
FORTBILDUNG_MESSEN: "Fortbildungskosten/Messen",
BEITRAEGE: "Beiträge",
VERSICHERUNGEN: "Versicherungen",
WERBEKOSTEN: "Werbekosten",
ZINSEN: "Zinsen",
REISEKOSTEN: "Reisekosten",
REPARATUREN_INSTANDHALTUNG: "Reparaturen / Instandhaltung",
BUEROBEDARF: "Bürobedarf",
REPRAESENTATIONSKOSTEN: "Repräsentationskosten",
SONSTIGER_BETRIEBSBEDARF: "Sonstiger Betriebsbedarf",
NEBENKOSTEN_GELDVERKEHR: "Nebenkosten des Geldverkehrs",
};
+244
View File
@@ -0,0 +1,244 @@
/**
* Client-side validation and debugging utilities
* Mirrors server-side validation for real-time user feedback
*/
// Debug mode - enable via localStorage: localStorage.setItem('DEBUG_MODE', 'true')
export function isDebugMode(): boolean {
if (typeof window === "undefined") return false;
return localStorage.getItem("DEBUG_MODE") === "true";
}
export function setDebugMode(enabled: boolean): void {
if (typeof window === "undefined") return;
if (enabled) {
localStorage.setItem("DEBUG_MODE", "true");
console.log("✅ DEBUG MODE ENABLED");
} else {
localStorage.removeItem("DEBUG_MODE");
console.log("❌ DEBUG MODE DISABLED");
}
}
/**
* Log error to browser console (only in debug mode)
*/
export function debugLog(type: string, message: string, data?: unknown): void {
if (!isDebugMode()) return;
const style = {
error: "color: #ef4444; font-weight: bold;",
warning: "color: #f97316; font-weight: bold;",
info: "color: #3b82f6; font-weight: bold;",
success: "color: #22c55e; font-weight: bold;",
};
const typeStyle = style[type as keyof typeof style] || style.info;
console.log(`%c[${type.toUpperCase()}] ${message}`, typeStyle, data);
}
/**
* Validation error type
*/
export interface ValidationError {
field: string;
message: string;
}
/**
* Validate tax ID (Steuernummer): 10 digits
*/
export function validateTaxId(val: string | null | undefined): ValidationError | null {
if (!val || val === "") return null;
if (!/^\d{10}$/.test(val)) {
return { field: "taxId", message: "Steuernummer muss 10 Ziffern haben" };
}
return null;
}
/**
* Validate VAT ID (USt-IdNr): DE + 9 digits
*/
export function validateVatId(val: string | null | undefined): ValidationError | null {
if (!val || val === "") return null;
if (!/^DE\d{9}$/.test(val)) {
return { field: "vatId", message: "USt-IdNr. muss im Format DE + 9 Ziffern sein" };
}
return null;
}
/**
* Validate IBAN
*/
export function validateIban(val: string | null | undefined): ValidationError | null {
if (!val || val === "") return null;
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(val)) {
return { field: "bankIban", message: "Ungültige IBAN" };
}
return null;
}
/**
* Validate BIC
*/
export function validateBic(val: string | null | undefined): ValidationError | null {
if (!val || val === "") return null;
if (!/^[A-Z0-9]{8,11}$/.test(val)) {
return { field: "bankBic", message: "Ungültiger BIC" };
}
return null;
}
/**
* Validate website URL
*/
export function validateWebsite(val: string | null | undefined): ValidationError | null {
if (!val || val === "") return null;
if (!/^https?:\/\//.test(val)) {
return { field: "website", message: "Website muss mit http:// oder https:// beginnen" };
}
if (val.length > 255) {
return { field: "website", message: "Website darf maximal 255 Zeichen sein" };
}
return null;
}
/**
* Validate company form data
*/
export interface CompanyFormData {
name: string;
address: string;
zip: string;
city: string;
taxId?: string;
vatId?: string;
website?: string;
bankIban?: string;
bankBic?: string;
email?: string;
phone?: string;
legalForm?: string;
country?: string;
bankName?: string;
}
export function validateCompanyForm(
data: CompanyFormData
): ValidationError[] {
const errors: ValidationError[] = [];
// Required fields
if (!data.name || data.name.trim() === "") {
errors.push({ field: "name", message: "Firmenname erforderlich" });
} else if (data.name.length > 255) {
errors.push({ field: "name", message: "Firmenname darf maximal 255 Zeichen sein" });
}
if (!data.address || data.address.trim() === "") {
errors.push({ field: "address", message: "Adresse erforderlich" });
} else if (data.address.length > 500) {
errors.push({ field: "address", message: "Adresse darf maximal 500 Zeichen sein" });
}
if (!data.zip || data.zip.trim() === "") {
errors.push({ field: "zip", message: "PLZ erforderlich" });
} else if (!/^[\d\s-]*$/.test(data.zip)) {
errors.push({ field: "zip", message: "PLZ darf nur Zahlen, Bindestriche und Leerzeichen enthalten" });
} else if (data.zip.length > 20) {
errors.push({ field: "zip", message: "PLZ darf maximal 20 Zeichen sein" });
}
if (!data.city || data.city.trim() === "") {
errors.push({ field: "city", message: "Stadt erforderlich" });
} else if (data.city.length > 100) {
errors.push({ field: "city", message: "Stadt darf maximal 100 Zeichen sein" });
}
// Optional fields with validation
if (data.taxId) {
const taxError = validateTaxId(data.taxId);
if (taxError) errors.push(taxError);
}
if (data.vatId) {
const vatError = validateVatId(data.vatId);
if (vatError) errors.push(vatError);
}
if (data.website) {
const webError = validateWebsite(data.website);
if (webError) errors.push(webError);
}
if (data.bankIban) {
const ibanError = validateIban(data.bankIban);
if (ibanError) errors.push(ibanError);
}
if (data.bankBic) {
const bicError = validateBic(data.bankBic);
if (bicError) errors.push(bicError);
}
if (data.email && data.email.trim()) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.push({ field: "email", message: "Ungültige E-Mail-Adresse" });
}
}
if (data.phone && data.phone.length > 20) {
errors.push({ field: "phone", message: "Telefonnummer darf maximal 20 Zeichen sein" });
}
if (data.legalForm && data.legalForm.length > 100) {
errors.push({ field: "legalForm", message: "Rechtsform darf maximal 100 Zeichen sein" });
}
if (data.country && data.country.length > 2) {
errors.push({ field: "country", message: "Ländercode darf maximal 2 Zeichen sein" });
}
if (data.bankName && data.bankName.length > 255) {
errors.push({ field: "bankName", message: "Bankname darf maximal 255 Zeichen sein" });
}
// Debug logging
if (errors.length > 0) {
debugLog("warning", `Validation failed: ${errors.length} error(s)`, errors);
} else {
debugLog("success", "Validation passed", data);
}
return errors;
}
/**
* Validate API response errors
*/
export function handleApiError(error: unknown, endpoint: string): void {
debugLog("error", `API Error from ${endpoint}`, error);
if (error instanceof Response) {
debugLog("error", `HTTP ${error.status}: ${error.statusText}`, error);
} else if (error instanceof Error) {
debugLog("error", error.message, error.stack);
} else {
debugLog("error", "Unknown error", error);
}
}
/**
* Get error message for a specific field
*/
export function getFieldError(field: string, errors: ValidationError[]): string | null {
const error = errors.find((e) => e.field === field);
return error?.message || null;
}
/**
* Check if field has error
*/
export function hasFieldError(field: string, errors: ValidationError[]): boolean {
return errors.some((e) => e.field === field);
}
+73
View File
@@ -0,0 +1,73 @@
import { execSync } from "node:child_process";
import prisma from "./prisma.server";
let initStarted = false;
let initCompleted = false;
/**
* Run Prisma migrations to bring database schema up to date
*/
async function runMigrations(): Promise<void> {
try {
console.log("[DB Init] Running Prisma migrations...");
execSync("npx prisma migrate deploy", {
stdio: "inherit",
env: { ...process.env },
});
console.log("[DB Init] ✓ Migrations completed successfully");
} catch (error) {
console.error("[DB Init] ✗ Migration failed:", error);
throw new Error("Database migration failed. See logs above.");
}
}
/**
* Initialize database: run all pending migrations
* Safe to call multiple times (idempotent)
*/
export async function initializeDatabase(): Promise<void> {
// Prevent concurrent initialization attempts
if (initCompleted) return;
if (initStarted) {
// Wait for initialization to complete
while (!initCompleted) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
return;
}
initStarted = true;
try {
await runMigrations();
console.log("[DB Init] ✓ Database initialization complete");
} catch (error) {
console.error("[DB Init] Fatal error during database initialization:", error);
throw error;
} finally {
initCompleted = true;
}
}
/**
* Check database health (non-blocking)
*/
export async function checkDatabaseHealth(): Promise<{
connected: boolean;
isEmpty: boolean;
error?: string;
}> {
try {
const userCount = await prisma.user.count();
return {
connected: true,
isEmpty: userCount === 0,
};
} catch (error) {
return {
connected: false,
isEmpty: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
+27
View File
@@ -0,0 +1,27 @@
export const EINNAHME_KATEGORIEN = [
"FUSSPFLEGE",
"PRIVATEINLAGEN",
"DARLEHEN",
"STEUERERSTATTUNGEN",
"VERSICHERUNGSERSTATTUNGEN",
"ZINSERTRAEGE",
"VERMIETUNG_VERPACHTUNG",
"VERAEUSSERUNGSERLOES",
"EIGENVERBRAUCH",
"SONSTIGE_EINNAHMEN",
] as const;
export type EinnahmeKategorieKey = typeof EINNAHME_KATEGORIEN[number];
export const EINNAHME_LABELS: Record<EinnahmeKategorieKey, string> = {
FUSSPFLEGE: "Fußpflege/Verkauf/Gutscheine",
PRIVATEINLAGEN: "Privateinlagen",
DARLEHEN: "Darlehen",
STEUERERSTATTUNGEN: "Steuererstattungen",
VERSICHERUNGSERSTATTUNGEN: "Versicherungserstattungen",
ZINSERTRAEGE: "Zinserträge",
VERMIETUNG_VERPACHTUNG: "Miet-/Pachteinnahmen",
VERAEUSSERUNGSERLOES: "Veräußerungserlöse",
EIGENVERBRAUCH: "Eigenverbrauch",
SONSTIGE_EINNAHMEN: "Sonstige Einnahmen",
};
+227
View File
@@ -0,0 +1,227 @@
/**
* Comprehensive server-side error logging for debugging
* Captures errors with full context: stack traces, request details, environment
*/
export interface ErrorContext {
request?: Request;
userId?: string | null;
route?: string;
metadata?: Record<string, unknown>;
}
export interface ErrorLogEntry {
timestamp: string;
type: string;
message: string;
stack?: string;
context?: {
method?: string;
url?: string;
route?: string;
userId?: string | null;
ipAddress?: string | null;
userAgent?: string;
metadata?: Record<string, unknown>;
};
environment: string;
}
/**
* Extract error message and stack from any error type
*/
function extractErrorInfo(error: unknown): { message: string; stack?: string } {
if (error instanceof Error) {
return {
message: error.message,
stack: error.stack,
};
}
if (typeof error === "string") {
return { message: error };
}
if (error && typeof error === "object") {
const err = error as Record<string, unknown>;
return {
message: (err.message as string) || String(error),
stack: (err.stack as string) || undefined,
};
}
return { message: String(error) };
}
/**
* Extract request context
*/
function extractRequestContext(
request?: Request
): ErrorLogEntry["context"] {
if (!request) return {};
const url = new URL(request.url);
const ipAddress =
request.headers.get("x-forwarded-for") ??
request.headers.get("x-real-ip") ??
null;
const userAgent = request.headers.get("user-agent");
return {
method: request.method,
url: url.pathname + url.search,
ipAddress,
userAgent: userAgent ?? undefined,
};
}
/**
* Build comprehensive error log entry
*/
function buildErrorLogEntry(
type: string,
error: unknown,
context?: ErrorContext
): ErrorLogEntry {
const { message, stack } = extractErrorInfo(error);
const requestContext = extractRequestContext(context?.request);
return {
timestamp: new Date().toISOString(),
type,
message,
stack,
context: {
...requestContext,
route: context?.route,
userId: context?.userId,
metadata: context?.metadata,
},
environment: process.env.NODE_ENV || "development",
};
}
/**
* Format error log for console output
*/
function formatErrorLog(entry: ErrorLogEntry): string {
const parts = [
`[${entry.type}]`,
`${entry.timestamp}`,
entry.message,
];
if (entry.context?.route) parts.push(`route: ${entry.context.route}`);
if (entry.context?.method && entry.context?.url) {
parts.push(`${entry.context.method} ${entry.context.url}`);
}
if (entry.context?.userId) parts.push(`user: ${entry.context.userId}`);
if (entry.context?.ipAddress) parts.push(`ip: ${entry.context.ipAddress}`);
let output = parts.join(" | ");
if (entry.stack) {
output += "\n" + entry.stack;
}
if (entry.context?.metadata) {
output += "\nMetadata: " + JSON.stringify(entry.context.metadata, null, 2);
}
return output;
}
/**
* Log a route/loader error
*/
export function logRouteError(
error: unknown,
context: ErrorContext & { route: string }
): void {
const entry = buildErrorLogEntry("ROUTE_ERROR", error, context);
console.error("\n" + "=".repeat(80));
console.error(formatErrorLog(entry));
console.error("=".repeat(80) + "\n");
}
/**
* Log an action/mutation error
*/
export function logActionError(
error: unknown,
context: ErrorContext & { action: string }
): void {
const entry = buildErrorLogEntry("ACTION_ERROR", error, {
...context,
metadata: {
action: context.action,
...context.metadata,
},
});
console.error("\n" + "=".repeat(80));
console.error(formatErrorLog(entry));
console.error("=".repeat(80) + "\n");
}
/**
* Log a database error
*/
export function logDatabaseError(
error: unknown,
operation: string,
context?: Omit<ErrorContext, "metadata">
): void {
const entry = buildErrorLogEntry("DATABASE_ERROR", error, {
...context,
metadata: { operation },
});
console.error("\n" + "=".repeat(80));
console.error(formatErrorLog(entry));
console.error("=".repeat(80) + "\n");
}
/**
* Log an API error
*/
export function logApiError(
error: unknown,
context: ErrorContext & { endpoint: string; statusCode?: number }
): void {
const entry = buildErrorLogEntry("API_ERROR", error, {
...context,
metadata: {
endpoint: context.endpoint,
statusCode: context.statusCode || 500,
...context.metadata,
},
});
console.error("\n" + "=".repeat(80));
console.error(formatErrorLog(entry));
console.error("=".repeat(80) + "\n");
}
/**
* Log a server startup error
*/
export function logStartupError(error: unknown): void {
const entry = buildErrorLogEntry("STARTUP_ERROR", error);
console.error("\n" + "=".repeat(80));
console.error("🚨 CRITICAL: Server failed to start");
console.error(formatErrorLog(entry));
console.error("=".repeat(80) + "\n");
}
/**
* Log a generic error with type
*/
export function logError(
type: string,
error: unknown,
context?: ErrorContext
): void {
const entry = buildErrorLogEntry(type, error, context);
console.error("\n" + "=".repeat(80));
console.error(formatErrorLog(entry));
console.error("=".repeat(80) + "\n");
}
+32
View File
@@ -0,0 +1,32 @@
export const DEFAULT_AUSGABE_KATEGORIEN = [
"Waren, Rohstoffe, Hilfsstoffe",
"Geringwertige Wirtschaftsgüter",
"Abschreibungen",
"Miete",
"Strom, Wasser",
"Telekommunikationskosten",
"Fortbildungskosten/Messen",
"Beiträge",
"Versicherungen",
"Werbekosten",
"Zinsen",
"Reisekosten",
"Reparaturen / Instandhaltung",
"Bürobedarf",
"Repräsentationskosten",
"Sonstiger Betriebsbedarf",
"Nebenkosten des Geldverkehrs",
];
export const DEFAULT_EINNAHME_KATEGORIEN = [
"Fußpflege/Verkauf/Gutscheine",
"Privateinlagen",
"Darlehen",
"Steuererstattungen",
"Versicherungserstattungen",
"Zinserträge",
"Miet-/Pachteinnahmen",
"Veräußerungserlöse",
"Eigenverbrauch",
"Sonstige Einnahmen",
];
+26 -3
View File
@@ -4,15 +4,26 @@ export type LogAction =
| "LOGIN"
| "LOGIN_FAILED"
| "LOGOUT"
| "CHANGE_PASSWORD"
| "CREATE_USER"
| "UPDATE_USER"
| "DELETE_USER"
| "CREATE_COMPANY"
| "UPDATE_COMPANY"
| "DELETE_COMPANY"
| "ARCHIVE_COMPANY"
| "CREATE_INVOICE"
| "UPDATE_INVOICE"
| "DELETE_INVOICE";
| "DELETE_INVOICE"
| "UPDATE_INVOICE_STATUS"
| "CREATE_CUSTOMER"
| "UPDATE_CUSTOMER"
| "DELETE_CUSTOMER"
| "CREATE_SERVICE"
| "UPDATE_SERVICE"
| "DELETE_SERVICE"
| "UPLOAD_BELEG"
| "DELETE_BELEG";
export async function log({
userId,
@@ -36,13 +47,25 @@ export async function log({
: undefined;
try {
// Validate that userId exists in the database if provided
let validatedUserId = userId;
if (userId) {
const userExists = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!userExists) {
validatedUserId = null; // User doesn't exist, log as anonymous
}
}
await prisma.auditLog.create({
data: {
userId: userId ?? null,
userId: validatedUserId ?? null,
action,
entity: entity ?? null,
entityId: entityId ?? null,
metadata: metadata ?? undefined,
metadata: (metadata as any) ?? undefined,
ipAddress: ipAddress ?? null,
},
});
+21
View File
@@ -0,0 +1,21 @@
import { RateLimiterMemory } from "rate-limiter-flexible";
// Max. 15 Loginversuche pro IP innerhalb von 3 Minuten
const loginLimiter = new RateLimiterMemory({
points: 15,
duration: 60 * 3,
});
export async function checkLoginRateLimit(request: Request): Promise<string | null> {
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ??
request.headers.get("x-real-ip") ??
"unknown";
try {
await loginLimiter.consume(ip);
return null;
} catch {
return "Zu viele Loginversuche. Bitte 15 Minuten warten.";
}
}
+239
View File
@@ -0,0 +1,239 @@
import { z } from "zod";
import { InvoiceStatus } from "@prisma/client";
// ===== Reusable validators =====
/**
* Validates that a decimal string has at most 2 decimal places
* (required for currency/money fields in MySQL DECIMAL(10,2))
*/
export const currencySchema = z
.number()
.nonnegative("Geldbeträge dürfen nicht negativ sein")
.refine(
(n) => {
const decimal = n.toString().split(".")[1];
return !decimal || decimal.length <= 2;
},
"Geldbeträge dürfen maximal 2 Dezimalstellen haben"
);
/**
* Tax rate must be one of the valid German VAT rates
*/
export const taxRateSchema = z
.number()
.int("Steuersatz muss eine ganze Zahl sein")
.refine(
(r) => [0, 7, 19].includes(r),
"Steuersatz muss 0 (steuerfrei), 7 (ermäßigt) oder 19 (Standard) sein"
);
/**
* IBAN validation: 15-34 characters, starts with 2 letters + 2 digits
*/
export const ibanSchema = z
.string()
.refine(
(iban) => iban === "" || /^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(iban),
"Ungültige IBAN"
);
/**
* German tax ID (Steuernummer): 10 digits
*/
export const taxIdSchema = z
.string()
.refine(
(val) => val === "" || /^\d{10}$/.test(val),
"Steuernummer muss 10 Ziffern haben"
);
/**
* VAT ID (Umsatzsteuer-IdNr): DE + 9 digits
*/
export const vatIdSchema = z
.string()
.refine(
(val) => val === "" || /^DE\d{9}$/.test(val),
"USt-IdNr. muss im Format DE + 9 Ziffern sein"
);
// ===== Invoice Schemas =====
export const invoiceItemSchema = z.object({
position: z
.number()
.int("Position muss eine ganze Zahl sein")
.positive("Position muss größer als 0 sein"),
description: z
.string()
.min(1, "Beschreibung erforderlich")
.max(500, "Beschreibung darf maximal 500 Zeichen sein"),
quantity: z
.number()
.positive("Menge muss größer als 0 sein")
.refine(
(q) => {
const decimal = q.toString().split(".")[1];
return !decimal || decimal.length <= 3;
},
"Menge darf maximal 3 Dezimalstellen haben"
),
unit: z
.string()
.max(50, "Einheit darf maximal 50 Zeichen sein")
.optional(),
unitPrice: currencySchema,
taxRate: taxRateSchema,
netAmount: currencySchema,
taxAmount: currencySchema,
grossAmount: currencySchema,
});
export const invoiceSchema = z.object({
companyId: z.string().min(1, "Mandant erforderlich"),
customerId: z.string().min(1, "Kunde erforderlich"),
issueDate: z
.string()
.refine(
(d) => !isNaN(Date.parse(d)),
"Ungültiges Datum"
),
deliveryDate: z
.string()
.refine((d) => !isNaN(Date.parse(d)), "Ungültiges Datum")
.optional(),
dueDate: z
.string()
.refine((d) => !isNaN(Date.parse(d)), "Ungültiges Datum"),
notes: z
.string()
.max(5000, "Notizen darf maximal 5000 Zeichen sein")
.optional(),
kleinunternehmer: z.boolean().optional().default(false),
items: z
.array(invoiceItemSchema)
.min(1, "Mindestens ein Rechnungsposition erforderlich"),
netTotal: currencySchema,
taxTotal: currencySchema,
grossTotal: currencySchema,
});
export const invoiceUpdateSchema = invoiceSchema.omit({ companyId: true });
export const invoiceStatusSchema = z.object({
status: z.nativeEnum(InvoiceStatus),
});
// ===== Company Schemas =====
export const companySchema = z.object({
name: z
.string()
.min(1, "Firmenname erforderlich")
.max(255, "Firmenname darf maximal 255 Zeichen sein"),
legalForm: z
.string()
.max(100, "Rechtsform darf maximal 100 Zeichen sein")
.optional(),
taxId: taxIdSchema.nullable(),
vatId: vatIdSchema.nullable(),
address: z
.string()
.min(1, "Adresse erforderlich")
.max(500, "Adresse darf maximal 500 Zeichen sein"),
zip: z
.string()
.min(1, "PLZ erforderlich")
.max(20, "PLZ darf maximal 20 Zeichen sein")
.regex(/^[\d\s-]*$/, "PLZ darf nur Zahlen, Bindestriche und Leerzeichen enthalten"),
city: z
.string()
.min(1, "Stadt erforderlich")
.max(100, "Stadt darf maximal 100 Zeichen sein"),
country: z
.string()
.max(2, "Ländercode darf maximal 2 Zeichen sein")
.optional()
.default("DE"),
email: z
.string()
.email("Ungültige E-Mail-Adresse")
.optional()
.or(z.literal("")),
phone: z
.string()
.max(20, "Telefonnummer darf maximal 20 Zeichen sein")
.optional(),
website: z
.string()
.refine(
(val) => val === "" || /^https?:\/\//.test(val),
"Website muss mit http:// oder https:// beginnen"
)
.max(255, "Website darf maximal 255 Zeichen sein")
.optional()
.or(z.literal("")),
bankIban: ibanSchema.nullable(),
bankBic: z
.string()
.refine(
(val) => val === "" || /^[A-Z0-9]{8,11}$/.test(val),
"Ungültiger BIC"
)
.optional()
.or(z.literal("")),
bankName: z
.string()
.max(255, "Bankname darf maximal 255 Zeichen sein")
.optional(),
invoicePrefix: z
.string()
.max(10, "Rechnungsprefix darf maximal 10 Zeichen sein")
.optional()
.default("RE"),
kleinunternehmer: z.boolean().optional().default(false),
});
export const companyUpdateSchema = companySchema;
// ===== Customer Schemas =====
export const customerSchema = z.object({
companyId: z.string().min(1, "Mandant erforderlich"),
name: z
.string()
.min(1, "Kundenname erforderlich")
.max(255, "Kundenname darf maximal 255 Zeichen sein"),
taxId: taxIdSchema.optional(),
address: z
.string()
.min(1, "Adresse erforderlich")
.max(500, "Adresse darf maximal 500 Zeichen sein"),
zip: z
.string()
.min(1, "PLZ erforderlich")
.max(20, "PLZ darf maximal 20 Zeichen sein")
.regex(/^[\d\s-]*$/, "PLZ darf nur Zahlen, Bindestriche und Leerzeichen enthalten"),
city: z
.string()
.min(1, "Stadt erforderlich")
.max(100, "Stadt darf maximal 100 Zeichen sein"),
country: z
.string()
.max(2, "Ländercode darf maximal 2 Zeichen sein")
.optional()
.default("DE"),
email: z
.string()
.email("Ungültige E-Mail-Adresse")
.optional()
.or(z.literal("")),
phone: z
.string()
.max(20, "Telefonnummer darf maximal 20 Zeichen sein")
.optional(),
});
export const customerUpdateSchema = customerSchema.omit({ companyId: true });
+51 -6
View File
@@ -1,21 +1,61 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useRouteError } from "react-router";
import "./app.css";
import { DebugPanel } from "./components/debug-panel";
export function ErrorBoundary() {
const error = useRouteError();
const message = isRouteErrorResponse(error)
// Get error details
const isResponse = isRouteErrorResponse(error);
const status = isResponse ? error.status : 500;
const statusText = isResponse ? error.statusText : "Internal Server Error";
const message = isResponse
? `${error.status} ${error.statusText}`
: error instanceof Error
? error.message
: String(error);
const stack = error instanceof Error ? error.stack : undefined;
// Log error details for debugging
if (typeof console !== "undefined") {
console.error("\n" + "=".repeat(80));
console.error("[ERROR_BOUNDARY]", new Date().toISOString());
console.error(`Status: ${status} ${statusText}`);
console.error(`Message: ${message}`);
if (stack) console.error("Stack:\n" + stack);
if (error && typeof error === "object") {
console.error("Full Error Object:", error);
}
console.error("=".repeat(80) + "\n");
}
return (
<html lang="de">
<head><meta charSet="utf-8" /><Meta /><Links /></head>
<head>
<meta charSet="utf-8" />
<Meta />
<Links />
</head>
<body style={{ fontFamily: "monospace", padding: "2rem", background: "#fff1f2", color: "#9f1239" }}>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>Fehler</h1>
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", background: "#ffe4e6", padding: "1rem", borderRadius: "0.5rem" }}>{message}</pre>
{stack && <pre style={{ marginTop: "1rem", fontSize: "0.75rem", color: "#64748b", whiteSpace: "pre-wrap" }}>{stack}</pre>}
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>
{status} Fehler
</h1>
<p style={{ marginBottom: "1rem", fontSize: "0.9rem" }}>
{statusText}
</p>
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", background: "#ffe4e6", padding: "1rem", borderRadius: "0.5rem" }}>
{message}
</pre>
{import.meta.env.DEV && stack && (
<details style={{ marginTop: "1rem" }}>
<summary style={{ cursor: "pointer", fontWeight: 600, color: "#64748b" }}>
Stack Trace (Dev Only)
</summary>
<pre style={{ marginTop: "0.5rem", fontSize: "0.75rem", color: "#64748b", whiteSpace: "pre-wrap", background: "#f1f5f9", padding: "1rem", borderRadius: "0.5rem" }}>
{stack}
</pre>
</details>
)}
<Scripts />
</body>
</html>
@@ -45,5 +85,10 @@ export function Layout({ children }: { children: React.ReactNode }) {
}
export default function App() {
return <Outlet />;
return (
<>
<Outlet />
<DebugPanel />
</>
);
}
+25
View File
@@ -17,12 +17,22 @@ export default [
route("companies/:id/invoices/:invoiceId", "routes/companies.$id.invoices.$invoiceId.tsx"),
route("companies/:id/invoices/:invoiceId/edit", "routes/companies.$id.invoices.$invoiceId.edit.tsx"),
route("companies/:id/reports", "routes/companies.$id.reports.tsx"),
layout("routes/companies.$id.buchhaltung.tsx", [
route("companies/:id/buchhaltung/bilanzen", "routes/companies.$id.buchhaltung.bilanzen.tsx"),
route("companies/:id/buchhaltung/ausgaben", "routes/companies.$id.buchhaltung.ausgaben.tsx"),
route("companies/:id/buchhaltung/ausgaben/kategorien", "routes/companies.$id.buchhaltung.ausgaben.kategorien.tsx"),
route("companies/:id/buchhaltung/einnahmen", "routes/companies.$id.buchhaltung.einnahmen.tsx"),
route("companies/:id/buchhaltung/einnahmen/kategorien", "routes/companies.$id.buchhaltung.einnahmen.kategorien.tsx"),
route("companies/:id/buchhaltung/anlagevermoegen", "routes/companies.$id.buchhaltung.anlagevermoegen.tsx"),
route("companies/:id/buchhaltung/money", "routes/companies.$id.buchhaltung.money.tsx"),
]),
route("archiv", "routes/archiv.tsx"),
route("settings/password", "routes/settings.password.tsx"),
]),
// Admin routes
layout("routes/admin-layout.tsx", [
route("admin/mandanten", "routes/admin.mandanten.tsx"),
route("admin/users", "routes/admin.users.tsx"),
route("admin/users/new", "routes/admin.users.new.tsx"),
route("admin/users/:id", "routes/admin.users.$id.tsx"),
@@ -34,6 +44,8 @@ export default [
route("api/companies/:id", "routes/api.companies.$id.ts"),
route("api/companies/:id/customers", "routes/api.companies.$id.customers.ts"),
route("api/companies/:id/invoices", "routes/api.companies.$id.invoices.ts"),
route("api/companies/:id/money", "routes/api.companies.$id.money.ts"),
route("api/admin/companies/:id/delete", "routes/api.admin.companies.$id.delete.ts"),
route("api/customers", "routes/api.customers.ts"),
route("api/customers/:id", "routes/api.customers.$id.ts"),
route("api/services", "routes/api.services.ts"),
@@ -41,5 +53,18 @@ export default [
route("api/invoices", "routes/api.invoices.ts"),
route("api/invoices/:id", "routes/api.invoices.$id.ts"),
route("api/invoices/:id/pdf", "routes/api.invoices.$id.pdf.ts"),
route("api/invoices/:id/xml", "routes/api.invoices.$id.xml.ts"),
route("api/reports", "routes/api.reports.ts"),
route("api/bilanzen", "routes/api.bilanzen.ts"),
route("api/ausgaben", "routes/api.ausgaben.ts"),
route("api/ausgaben/:id", "routes/api.ausgaben.$id.ts"),
route("api/einnahmen", "routes/api.einnahmen.ts"),
route("api/einnahmen/:id", "routes/api.einnahmen.$id.ts"),
route("api/einnahmen/:id/upload", "routes/api.einnahmen.$id.upload.ts"),
route("api/beleg/:userId/:filename", "routes/api.beleg.$userId.$filename.ts"),
route("api/companies/:id/buchungkategorien", "routes/api.companies.$id.buchungkategorien.ts"),
route("api/companies/:id/buchungkategorien/:katId", "routes/api.companies.$id.buchungkategorien.$katId.ts"),
route("api/anlagevermoegen", "routes/api.anlagevermoegen.ts"),
route("api/anlagevermoegen/:id", "routes/api.anlagevermoegen.$id.ts"),
] satisfies RouteConfig;
+2 -1
View File
@@ -1,6 +1,6 @@
import { Outlet, useLoaderData, Link, useLocation } from "react-router";
import { requireAdmin } from "@/session.server";
import { Shield, Users, ScrollText, LayoutDashboard } from "lucide-react";
import { Shield, Users, ScrollText, LayoutDashboard, Building2 } from "lucide-react";
export async function loader({ request }: { request: Request }) {
const user = await requireAdmin(request);
@@ -12,6 +12,7 @@ export default function AdminLayout() {
const location = useLocation();
const navItems = [
{ to: "/admin/mandanten", label: "Mandanten", icon: Building2 },
{ to: "/admin/users", label: "Benutzerverwaltung", icon: Users },
{ to: "/admin/logs", label: "Audit-Log", icon: ScrollText },
];
+175
View File
@@ -0,0 +1,175 @@
import { Link, useLoaderData } from "react-router";
import { requireAdmin } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { Badge } from "@/components/ui/badge";
import { Building2, Archive, Trash2 } from "lucide-react";
import { useState } from "react";
export async function loader({ request }: { request: Request }) {
await requireAdmin(request);
const companies = await prisma.company.findMany({
include: {
user: { select: { id: true, name: true, email: true } },
_count: { select: { invoices: true, customers: true } },
},
orderBy: [{ archived: "asc" }, { name: "asc" }],
});
return {
companies: companies.map((c) => ({
...c,
archivedAt: c.archivedAt?.toISOString() ?? null,
})),
};
}
export default function AdminMandanten() {
const { companies } = useLoaderData<typeof loader>();
const active = companies.filter((c) => !c.archived);
const archived = companies.filter((c) => c.archived);
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900">Alle Mandanten</h1>
<p className="text-sm text-slate-500 mt-1">
{companies.length} Mandanten gesamt · {active.length} aktiv · {archived.length} archiviert
</p>
</div>
<MandantenTabelle companies={active} title="Aktive Mandanten" />
{archived.length > 0 && (
<div className="mt-8">
<MandantenTabelle companies={archived} title="Archivierte Mandanten" archived />
</div>
)}
</div>
);
}
type Company = {
id: string;
name: string;
legalForm: string | null;
city: string;
email: string | null;
archived: boolean;
user: { id: string; name: string; email: string };
_count: { invoices: number; customers: number };
};
function MandantenTabelle({
companies,
title,
archived = false,
}: {
companies: Company[];
title: string;
archived?: boolean;
}) {
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async (companyId: string, companyName: string) => {
if (deleteConfirm !== companyId) {
setDeleteConfirm(companyId);
return;
}
setIsDeleting(true);
try {
const response = await fetch(`/api/admin/companies/${companyId}/delete`, {
method: "DELETE",
});
if (response.ok) {
// Reload the page to refresh the list
window.location.reload();
} else {
const error = await response.json();
alert(`Fehler beim Löschen: ${error.error || response.statusText}`);
setDeleteConfirm(null);
}
} catch (error) {
alert(`Fehler beim Löschen: ${error}`);
setDeleteConfirm(null);
} finally {
setIsDeleting(false);
}
};
if (companies.length === 0) return null;
return (
<div>
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3">
{title}
</h2>
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="text-left px-4 py-3 font-medium text-slate-600">Mandant</th>
<th className="text-left px-4 py-3 font-medium text-slate-600">Ort</th>
<th className="text-left px-4 py-3 font-medium text-slate-600">Benutzer</th>
<th className="text-right px-4 py-3 font-medium text-slate-600">Rechnungen</th>
<th className="text-right px-4 py-3 font-medium text-slate-600">Kunden</th>
<th className="text-right px-4 py-3 font-medium text-slate-600"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{companies.map((company) => (
<tr key={company.id} className="hover:bg-slate-50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-slate-400 shrink-0" />
<div>
<div className="font-medium text-slate-900 flex items-center gap-2">
{company.name}
{archived && (
<Archive className="w-3.5 h-3.5 text-slate-400" />
)}
</div>
{company.legalForm && (
<div className="text-xs text-slate-400">{company.legalForm}</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3 text-slate-600">{company.city}</td>
<td className="px-4 py-3">
<div className="text-slate-700">{company.user.name}</div>
<div className="text-xs text-slate-400">{company.user.email}</div>
</td>
<td className="px-4 py-3 text-right text-slate-600">{company._count.invoices}</td>
<td className="px-4 py-3 text-right text-slate-600">{company._count.customers}</td>
<td className="px-4 py-3 text-right space-x-2 flex justify-end">
<Link
to={`/companies/${company.id}`}
className="text-indigo-600 hover:text-indigo-800 font-medium text-xs"
>
Öffnen
</Link>
<button
onClick={() => handleDelete(company.id, company.name)}
disabled={isDeleting}
className={`text-xs font-medium flex items-center gap-1 px-2 py-1 rounded transition-colors ${
deleteConfirm === company.id
? "bg-red-100 text-red-700 hover:bg-red-200"
: "text-slate-500 hover:text-red-600"
} ${isDeleting ? "opacity-50 cursor-not-allowed" : ""}`}
>
<Trash2 className="w-3.5 h-3.5" />
{deleteConfirm === company.id ? "Bestätigen?" : "Löschen"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import { requireAdmin } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { log } from "@/lib/logger.server";
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireAdmin(request);
if (request.method !== "DELETE") {
return Response.json({ error: "Method not allowed" }, { status: 405 });
}
const company = await prisma.company.findUnique({
where: { id: params.id },
});
if (!company) {
return Response.json({ error: "Company not found" }, { status: 404 });
}
await prisma.company.delete({
where: { id: params.id },
});
await log({
userId: user.id,
action: "DELETE_COMPANY",
entity: "Company",
entityId: params.id,
metadata: { companyName: company.name },
request,
});
return Response.json({ ok: true });
}
+52
View File
@@ -0,0 +1,52 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const updateSchema = z.object({
bezeichnung: z.string().min(1),
anschaffungsdatum: z.string().min(1),
anschaffungskosten: z.number().positive(),
nutzungsdauerJahre: z.number().int().min(1),
restwert: z.number().min(0),
beschreibung: z.string().optional(),
aktiv: z.boolean(),
});
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const asset = await prisma.anlagegut.findFirst({
where: { id: params.id, company: { userId: user.id } },
});
if (!asset) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.anlagegut.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}
const body = await request.json();
const parsed = updateSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.anlagegut.update({
where: { id: params.id },
data: {
bezeichnung: parsed.data.bezeichnung,
anschaffungsdatum: new Date(parsed.data.anschaffungsdatum),
anschaffungskosten: parsed.data.anschaffungskosten,
nutzungsdauerJahre: parsed.data.nutzungsdauerJahre,
restwert: parsed.data.restwert,
beschreibung: parsed.data.beschreibung,
aktiv: parsed.data.aktiv,
},
});
return Response.json({
...updated,
anschaffungskosten: Number(updated.anschaffungskosten),
restwert: Number(updated.restwert),
anschaffungsdatum: updated.anschaffungsdatum.toISOString(),
});
}
+104
View File
@@ -0,0 +1,104 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
import { afaFuerJahr, buchwert, assetStatus } from "@/lib/afa";
const createSchema = z.object({
companyId: z.string().min(1),
bezeichnung: z.string().min(1),
anschaffungsdatum: z.string().min(1),
anschaffungskosten: z.number().positive(),
nutzungsdauerJahre: z.number().int().min(1),
restwert: z.number().min(0).default(0),
beschreibung: z.string().optional(),
aktiv: z.boolean().default(true),
});
function toRaw(a: {
anschaffungskosten: unknown;
nutzungsdauerJahre: number;
restwert: unknown;
anschaffungsdatum: Date;
aktiv: boolean;
}) {
return {
anschaffungskosten: Number(a.anschaffungskosten),
nutzungsdauerJahre: a.nutzungsdauerJahre,
restwert: Number(a.restwert),
anschaffungsdatum: a.anschaffungsdatum.toISOString(),
aktiv: a.aktiv,
};
}
export async function loader({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(request.url);
const companyId = searchParams.get("companyId");
const year = parseInt(searchParams.get("year") ?? String(new Date().getFullYear()));
if (!companyId) return Response.json({ error: "companyId required" }, { status: 400 });
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const assets = await prisma.anlagegut.findMany({
where: { companyId },
orderBy: { anschaffungsdatum: "asc" },
});
return Response.json({
year,
assets: assets.map((a) => {
const raw = toRaw(a);
return {
id: a.id,
bezeichnung: a.bezeichnung,
beschreibung: a.beschreibung,
anschaffungsdatum: a.anschaffungsdatum.toISOString(),
anschaffungskosten: raw.anschaffungskosten,
nutzungsdauerJahre: a.nutzungsdauerJahre,
restwert: raw.restwert,
aktiv: a.aktiv,
afaJahr: afaFuerJahr(raw, year),
buchwert: buchwert(raw, year),
status: assetStatus(raw, year),
};
}),
});
}
export async function action({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const company = await prisma.company.findFirst({
where: { id: parsed.data.companyId, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const asset = await prisma.anlagegut.create({
data: {
companyId: parsed.data.companyId,
bezeichnung: parsed.data.bezeichnung,
anschaffungsdatum: new Date(parsed.data.anschaffungsdatum),
anschaffungskosten: parsed.data.anschaffungskosten,
nutzungsdauerJahre: parsed.data.nutzungsdauerJahre,
restwert: parsed.data.restwert,
beschreibung: parsed.data.beschreibung,
aktiv: parsed.data.aktiv,
},
});
return Response.json({
...asset,
anschaffungskosten: Number(asset.anschaffungskosten),
restwert: Number(asset.restwert),
anschaffungsdatum: asset.anschaffungsdatum.toISOString(),
}, { status: 201 });
}
+50
View File
@@ -0,0 +1,50 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const updateSchema = z.object({
kategorie: z.string().min(1),
betrag: z.number().positive(),
steuersatz: z.number().min(0).default(0),
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
datum: z.string().min(1),
beschreibung: z.string().optional(),
});
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const buchung = await prisma.buchung.findFirst({
where: { id: params.id, company: { userId: user.id }, type: "ENTNAHME", isBusinessRecord: true },
});
if (!buchung) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.buchung.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}
const body = await request.json();
const parsed = updateSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.buchung.update({
where: { id: params.id },
data: {
kategorie: parsed.data.kategorie,
amount: parsed.data.betrag,
steuersatz: parsed.data.steuersatz,
zahlungsart: parsed.data.zahlungsart,
account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK",
date: new Date(parsed.data.datum),
description: parsed.data.beschreibung,
},
});
return Response.json({
...updated,
amount: Number(updated.amount),
date: updated.date.toISOString(),
});
}
+85
View File
@@ -0,0 +1,85 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const createSchema = z.object({
companyId: z.string().min(1),
kategorie: z.string().min(1),
betrag: z.number().positive(),
steuersatz: z.number().min(0).default(0),
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
datum: z.string().min(1),
beschreibung: z.string().optional(),
});
export async function loader({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(request.url);
const companyId = searchParams.get("companyId");
const year = searchParams.get("year") ? parseInt(searchParams.get("year")!) : null;
if (!companyId) return Response.json({ error: "companyId required" }, { status: 400 });
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const ausgaben = await prisma.buchung.findMany({
where: {
companyId,
type: "ENTNAHME",
isBusinessRecord: true,
...(year ? {
date: {
gte: new Date(`${year}-01-01`),
lt: new Date(`${year + 1}-01-01`),
},
} : {}),
},
orderBy: { date: "desc" },
});
return Response.json(
ausgaben.map((a) => ({
...a,
amount: Number(a.amount),
date: a.date.toISOString(),
}))
);
}
export async function action({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const company = await prisma.company.findFirst({
where: { id: parsed.data.companyId, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const ausgabe = await prisma.buchung.create({
data: {
companyId: parsed.data.companyId,
account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK",
type: "ENTNAHME",
amount: parsed.data.betrag,
date: new Date(parsed.data.datum),
description: parsed.data.beschreibung,
kategorie: parsed.data.kategorie,
steuersatz: parsed.data.steuersatz,
zahlungsart: parsed.data.zahlungsart,
isBusinessRecord: true,
},
});
return Response.json({
...ausgabe,
amount: Number(ausgabe.amount),
date: ausgabe.date.toISOString(),
}, { status: 201 });
}
+57
View File
@@ -0,0 +1,57 @@
import { readFile } from "node:fs/promises";
import { join, resolve, extname } from "node:path";
import { requireUser } from "@/session.server";
const MIME: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".pdf": "application/pdf",
};
function storageRoot(): string {
return resolve(process.env.BELEG_STORAGE_PATH ?? "data/documents");
}
export async function loader({
request,
params,
}: {
request: Request;
params: { userId: string; filename: string };
}) {
const user = await requireUser(request);
// Users may only access their own documents
if (params.userId !== user.id) {
throw new Response("Forbidden", { status: 403 });
}
// Prevent path traversal
const root = storageRoot();
const filePath = join(root, params.userId, params.filename);
if (!filePath.startsWith(root)) {
throw new Response("Forbidden", { status: 403 });
}
let data: Buffer;
try {
data = await readFile(filePath);
} catch {
throw new Response("Not Found", { status: 404 });
}
const ext = extname(params.filename).toLowerCase();
const contentType = MIME[ext] ?? "application/octet-stream";
const disposition = contentType === "application/pdf" ? "inline" : "inline";
return new Response(new Uint8Array(data), {
headers: {
"Content-Type": contentType,
"Content-Disposition": `${disposition}; filename="${params.filename}"`,
"Cache-Control": "private, max-age=3600",
},
});
}
+140
View File
@@ -0,0 +1,140 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { InvoiceStatus } from "@prisma/client";
export async function loader({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(request.url);
const companyId = searchParams.get("companyId");
const year = parseInt(searchParams.get("year") ?? String(new Date().getFullYear()));
if (!companyId) return Response.json({ error: "companyId required" }, { status: 400 });
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const yearStart = new Date(`${year}-01-01`);
const yearEnd = new Date(`${year + 1}-01-01`);
// GuV: alle Rechnungen des Jahres (PAID + SENT)
const guvInvoices = await prisma.invoice.findMany({
where: {
companyId,
status: { in: [InvoiceStatus.PAID, InvoiceStatus.SENT] },
issueDate: { gte: yearStart, lt: yearEnd },
},
include: { items: true },
});
// Umsatzerlöse nach Steuersatz
const erloeseByRate: Record<string, { netAmount: number; taxAmount: number; grossAmount: number }> = {};
for (const invoice of guvInvoices) {
for (const item of invoice.items) {
const rate = String(Number(item.taxRate));
if (!erloeseByRate[rate]) erloeseByRate[rate] = { netAmount: 0, taxAmount: 0, grossAmount: 0 };
erloeseByRate[rate].netAmount += Number(item.netAmount);
erloeseByRate[rate].taxAmount += Number(item.taxAmount);
erloeseByRate[rate].grossAmount += Number(item.grossAmount);
}
}
const guvNetto = guvInvoices.reduce((s, i) => s + Number(i.netTotal), 0);
const guvSteuer = guvInvoices.reduce((s, i) => s + Number(i.taxTotal), 0);
const guvBrutto = guvInvoices.reduce((s, i) => s + Number(i.grossTotal), 0);
// Bilanz-Stichtag: 31.12. des gewählten Jahres
// Forderungen = offene (SENT) Rechnungen bis Jahresende
const forderungenAgg = await prisma.invoice.aggregate({
where: { companyId, status: InvoiceStatus.SENT, issueDate: { lt: yearEnd } },
_sum: { grossTotal: true },
_count: true,
});
// Bank/Kasse-Näherung = bezahlte Rechnungen (brutto) bis Jahresende
const bankAgg = await prisma.invoice.aggregate({
where: { companyId, status: InvoiceStatus.PAID, issueDate: { lt: yearEnd } },
_sum: { grossTotal: true },
_count: true,
});
const forderungen = Number(forderungenAgg._sum.grossTotal ?? 0);
const bank = Number(bankAgg._sum.grossTotal ?? 0);
const summeAktiva = forderungen + bank;
// Betriebsausgaben für das Jahr (from buchungen with type=ENTNAHME and isBusinessRecord=true)
const ausgaben = await prisma.buchung.findMany({
where: { companyId, type: "ENTNAHME", isBusinessRecord: true, date: { gte: yearStart, lt: yearEnd } },
});
const ausgabenGesamt = ausgaben.reduce((s, a) => s + Number(a.amount), 0);
const ausgabenVorsteuer = ausgaben.reduce((s, a) => {
const brutto = Number(a.amount);
const rate = (a.steuersatz || 0) / 100;
return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0);
}, 0);
// Ausgaben nach Kategorie
const ausgabenByKategorieMap: Record<string, number> = {};
for (const a of ausgaben) {
const k = a.kategorie || "Sonstige";
ausgabenByKategorieMap[k] = (ausgabenByKategorieMap[k] ?? 0) + Number(a.amount);
}
const ausgabenByKategorie = Object.entries(ausgabenByKategorieMap).map(([kategorie, betrag]) => ({ kategorie, betrag }));
// Sonstige Einnahmen für das Jahr (from buchungen with type=EINLAGE and isBusinessRecord=true)
const einnahmen = await prisma.buchung.findMany({
where: { companyId, type: "EINLAGE", isBusinessRecord: true, date: { gte: yearStart, lt: yearEnd } },
});
const sonstigeEinnahmen = einnahmen.reduce((s, e) => s + Number(e.amount), 0);
const einnahmenUst = einnahmen.reduce((s, e) => {
const brutto = Number(e.amount);
const rate = (e.steuersatz || 0) / 100;
return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0);
}, 0);
// Kasse / Bank aus sonstigen Einnahmen und Ausgaben (nach Zahlungsart)
const einnahmenKasse = einnahmen.filter((e) => e.zahlungsart === "KASSE").reduce((s, e) => s + Number(e.amount), 0);
const ausgabenKasse = ausgaben.filter((a) => a.zahlungsart === "KASSE").reduce((s, a) => s + Number(a.amount), 0);
const einnahmenBank = einnahmen.filter((e) => e.zahlungsart === "BANK").reduce((s, e) => s + Number(e.amount), 0);
const ausgabenBank = ausgaben.filter((a) => a.zahlungsart === "BANK").reduce((s, a) => s + Number(a.amount), 0);
// Kasse-Saldo = bezahlte Rechnungen (Kasse-Anteil wird nicht getrennt) + sonstige Einnahmen Kasse - Ausgaben Kasse
// Bank-Näherung = bezahlte Rechnungen + sonstige Einnahmen Bank - Ausgaben Bank
const kasseNetto = einnahmenKasse - ausgabenKasse;
const bankNetto = bank + einnahmenBank - ausgabenBank;
const summeAktivaErweitert = forderungen + Math.max(0, bankNetto) + Math.max(0, kasseNetto);
const jahresergebnis = guvNetto + sonstigeEinnahmen - ausgabenGesamt;
return Response.json({
year,
kleinunternehmer: company.kleinunternehmer,
guv: {
erloeseByRate,
netTotal: guvNetto,
taxTotal: guvSteuer,
grossTotal: guvBrutto,
invoiceCount: guvInvoices.length,
ausgabenGesamt,
ausgabenVorsteuer,
ausgabenByKategorie,
sonstigeEinnahmen,
einnahmenUst,
jahresergebnis,
},
bilanz: {
aktiva: {
forderungen: { betrag: forderungen, anzahl: forderungenAgg._count },
bank: { betrag: Math.max(0, bankNetto), anzahl: bankAgg._count },
kasse: { betrag: Math.max(0, kasseNetto) },
summe: summeAktivaErweitert,
},
passiva: {
eigenkapital: summeAktivaErweitert,
summe: summeAktivaErweitert,
},
},
});
}
@@ -0,0 +1,51 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const updateSchema = z.object({
name: z.string().min(1, "Name ist erforderlich").max(100),
});
export async function action({
request,
params,
}: {
request: Request;
params: { id: string; katId: string };
}) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const kat = await prisma.buchungKategorie.findFirst({
where: { id: params.katId, companyId: params.id, company: { userId: user.id } },
});
if (!kat) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
const usedCount = await prisma.buchung.count({
where: { companyId: params.id, kategorie: kat.name, isBusinessRecord: true },
});
if (usedCount > 0) {
return Response.json(
{ error: `Kategorie wird von ${usedCount} Buchung(en) verwendet und kann nicht gelöscht werden.` },
{ status: 409 }
);
}
await prisma.buchungKategorie.delete({ where: { id: params.katId } });
return Response.json({ ok: true });
}
if (request.method === "PUT") {
const body = await request.json();
const parsed = updateSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.buchungKategorie.update({
where: { id: params.katId },
data: { name: parsed.data.name },
});
return Response.json(updated);
}
return Response.json({ error: "Method not allowed" }, { status: 405 });
}
@@ -0,0 +1,69 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const createSchema = z.object({
name: z.string().min(1, "Name ist erforderlich").max(100),
typ: z.enum(["AUSGABE", "EINNAHME"]),
});
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const company = await prisma.company.findFirst({
where: { id: params.id, userId: user.id },
select: { id: true },
});
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const { searchParams } = new URL(request.url);
const typ = searchParams.get("typ") as "AUSGABE" | "EINNAHME" | null;
if (!typ || !["AUSGABE", "EINNAHME"].includes(typ)) {
return Response.json({ error: "typ (AUSGABE|EINNAHME) required" }, { status: 400 });
}
const kats = await prisma.buchungKategorie.findMany({
where: { companyId: params.id, typ },
orderBy: { name: "asc" },
});
const withUsage = await Promise.all(
kats.map(async (k) => ({
id: k.id,
name: k.name,
typ: k.typ,
inUse:
(await prisma.buchung.count({
where: { companyId: params.id, kategorie: k.name, isBusinessRecord: true },
})) > 0,
}))
);
return Response.json(withUsage);
}
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const company = await prisma.company.findFirst({
where: { id: params.id, userId: user.id },
select: { id: true },
});
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const body = await request.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const kat = await prisma.buchungKategorie.create({
data: {
companyId: params.id,
name: parsed.data.name,
typ: parsed.data.typ,
},
});
return Response.json(kat, { status: 201 });
}
+101
View File
@@ -0,0 +1,101 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const createSchema = z.object({
name: z.string().min(1),
typ: z.enum(["EINNAHME", "AUSGABE"]),
});
/**
* Loader: GET all categories for a company
*
* Query params:
* - typ (optional): "EINNAHME" or "AUSGABE" to filter by type
*
* Returns a JSON array of BuchungKategorie records.
*/
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const company = await prisma.company.findFirst({
where: { id: params.id, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const { searchParams } = new URL(request.url);
const typ = searchParams.get("typ");
const kategorien = await prisma.buchungKategorie.findMany({
where: {
companyId: params.id,
...(typ ? { typ } : {}),
},
orderBy: { name: "asc" },
});
return Response.json(kategorien);
}
/**
* Action: POST to create a new category, DELETE to remove one
*
* POST body:
* { name: string, typ: "EINNAHME" | "AUSGABE" }
*
* DELETE query param:
* - kategorieId: the id of the category to delete
*/
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const company = await prisma.company.findFirst({
where: { id: params.id, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
if (request.method === "DELETE") {
const { searchParams } = new URL(request.url);
const kategorieId = searchParams.get("kategorieId");
if (!kategorieId) {
return Response.json({ error: "kategorieId required" }, { status: 400 });
}
// Check that kategorie belongs to this company
const kategorie = await prisma.buchungKategorie.findFirst({
where: { id: kategorieId, companyId: params.id },
});
if (!kategorie) return Response.json({ error: "Category not found" }, { status: 404 });
// Check if kategorie is in use
const inUse = await prisma.buchung.findFirst({
where: { kategorie: kategorie.name, companyId: params.id },
});
if (inUse) {
return Response.json(
{ error: "Category is in use and cannot be deleted" },
{ status: 409 }
);
}
await prisma.buchungKategorie.delete({ where: { id: kategorieId } });
return Response.json({ ok: true });
}
const body = await request.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const kategorie = await prisma.buchungKategorie.create({
data: {
companyId: params.id,
name: parsed.data.name,
typ: parsed.data.typ,
},
});
return Response.json(kategorie, { status: 201 });
}
+220
View File
@@ -0,0 +1,220 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
type Transaction = {
id: string;
date: string;
account: "kasse" | "bank";
type: "einlage" | "entnahme";
amount: number;
description: string;
isBusinessRecord: boolean;
kategorie: string | null;
};
function toTransaction(buchung: { id: string; date: Date; account: "KASSE" | "BANK"; type: "EINLAGE" | "ENTNAHME"; amount: { toString(): string } | number | string; description: string | null | undefined; isBusinessRecord: boolean; kategorie: string | null | undefined }): Transaction {
return {
id: buchung.id,
date: buchung.date.toISOString().split("T")[0],
account: buchung.account === "KASSE" ? "kasse" : "bank",
type: buchung.type === "EINLAGE" ? "einlage" : "entnahme",
amount: Number(buchung.amount),
description: buchung.description || "",
isBusinessRecord: buchung.isBusinessRecord,
kategorie: buchung.kategorie || null,
};
}
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { id } = params;
const company = await prisma.company.findFirst({
where: { id, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const buchungen = (await prisma.buchung.findMany({
where: { companyId: id },
orderBy: { date: "desc" },
select: {
id: true,
date: true,
account: true,
type: true,
amount: true,
description: true,
isBusinessRecord: true,
kategorie: true,
},
})) as unknown as Array<{ id: string; date: Date; account: "KASSE" | "BANK"; type: "EINLAGE" | "ENTNAHME"; amount: any; description: string | null; isBusinessRecord: boolean; kategorie: string | null }>;
const transactions = buchungen.map(toTransaction);
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number);
return Response.json({ transactions, balance });
}
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { id } = params;
const company = await prisma.company.findFirst({
where: { id, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const url = new URL(request.url);
const transactionId = url.searchParams.get("transactionId");
const method = request.method;
const data = await request.json().catch(() => ({}));
if (method === "POST") {
const amount = Number(data.amount);
if (!data.date || !data.account || Number.isNaN(amount) || amount <= 0) {
return Response.json({ error: "Ungültige Daten" }, { status: 400 });
}
// Check if this is an Umbuchung (transfer between accounts)
if (data.type === "umbuchung") {
if (!data.toAccount) {
return Response.json({ error: "toAccount erforderlich für Umbuchung" }, { status: 400 });
}
await prisma.$transaction(async (tx) => {
// ENTNAHME from source account
const entnahme = await tx.buchung.create({
data: {
companyId: id,
date: new Date(data.date),
account: data.account === "bank" ? "BANK" : "KASSE",
type: "ENTNAHME",
amount: amount,
description: data.description || "",
},
});
// EINLAGE to target account, linked to the entnahme
await tx.buchung.create({
data: {
companyId: id,
date: new Date(data.date),
account: data.toAccount === "bank" ? "BANK" : "KASSE",
type: "EINLAGE",
amount: amount,
description: data.description || "",
linkedBuchungId: entnahme.id,
},
});
});
} else {
if (!data.type) {
return Response.json({ error: "type erforderlich" }, { status: 400 });
}
await prisma.buchung.create({
data: {
companyId: id,
date: new Date(data.date),
account: data.account === "bank" ? "BANK" : "KASSE",
type: data.type === "entnahme" ? "ENTNAHME" : "EINLAGE",
amount: amount,
description: data.description || "",
},
});
}
} else if (method === "PUT") {
if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 });
const amount = Number(data.amount);
if (!data.date || !data.account || !data.type || Number.isNaN(amount) || amount <= 0) {
return Response.json({ error: "Ungültige Daten" }, { status: 400 });
}
const exist = (await prisma.buchung.findFirst({
where: { id: transactionId, companyId: id },
select: {
id: true,
date: true,
account: true,
type: true,
amount: true,
description: true,
isBusinessRecord: true,
},
})) as unknown as { id: string; date: Date; account: "KASSE" | "BANK"; type: "EINLAGE" | "ENTNAHME"; amount: any; description: string | null; isBusinessRecord: boolean } | null;
if (!exist) return Response.json({ error: "Transaction not found" }, { status: 404 });
// Block edit if this is an auto-created Buchung (from Einnahme/Ausgabe)
if (exist.isBusinessRecord) {
return Response.json(
{ error: "Automatisch erstellte Transaktionen können nicht direkt bearbeitet werden" },
{ status: 400 }
);
}
await prisma.buchung.update({
where: { id: transactionId },
data: {
date: new Date(data.date),
account: data.account === "bank" ? "BANK" : "KASSE",
type: data.type === "entnahme" ? "ENTNAHME" : "EINLAGE",
amount: amount,
description: data.description || "",
},
});
} else if (method === "DELETE") {
if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 });
const exist = await prisma.buchung.findFirst({ where: { id: transactionId, companyId: id } });
if (!exist) return Response.json({ error: "Transaction not found" }, { status: 404 });
// For Umbuchung (linked transactions), delete both
const linkedId = (exist as any).linkedBuchungId;
const isLinkedFrom = await prisma.buchung.findFirst({
where: { linkedBuchungId: transactionId } as any,
});
if (linkedId || isLinkedFrom) {
await prisma.$transaction(async (tx) => {
// If this is the ENTNAHME, delete linked EINLAGE
if (linkedId) {
await tx.buchung.deleteMany({ where: { id: linkedId } });
}
// If this is the EINLAGE, delete linked ENTNAHME
if (isLinkedFrom) {
await tx.buchung.deleteMany({ where: { id: isLinkedFrom.id } });
}
// Delete this entry
await tx.buchung.deleteMany({ where: { id: transactionId, companyId: id } });
});
} else {
// Regular transaction
await prisma.buchung.deleteMany({ where: { id: transactionId, companyId: id } });
}
} else {
return Response.json({ error: "Method not allowed" }, { status: 405 });
}
const buchungen = await prisma.buchung.findMany({
where: { companyId: id },
orderBy: { date: "desc" },
select: {
id: true,
date: true,
account: true,
type: true,
amount: true,
description: true,
isBusinessRecord: true,
kategorie: true,
},
});
const transactions = buchungen.map(toTransaction);
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number);
return Response.json({ transactions, balance });
}
+8 -22
View File
@@ -1,25 +1,7 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const companySchema = z.object({
name: z.string().min(1),
legalForm: z.string().optional(),
taxId: z.string().optional(),
vatId: z.string().optional(),
address: z.string().min(1),
zip: z.string().min(1),
city: z.string().min(1),
country: z.string().optional(),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
website: z.string().optional(),
bankIban: z.string().optional(),
bankBic: z.string().optional(),
bankName: z.string().optional(),
invoicePrefix: z.string().optional(),
kleinunternehmer: z.boolean().optional(),
});
import { log } from "@/lib/logger.server";
import { companyUpdateSchema } from "@/lib/schemas";
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
@@ -39,7 +21,8 @@ export async function action({ request, params }: { request: Request; params: {
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.company.delete({ where: { id: params.id } });
await prisma.company.delete({ where: { id: params.id, userId: user.id } });
await log({ userId: user.id, action: "DELETE_COMPANY", entity: "Company", entityId: params.id, request });
return Response.json({ ok: true });
}
@@ -53,14 +36,17 @@ export async function action({ request, params }: { request: Request; params: {
archivedAt: archive ? new Date() : null,
},
});
const action = archive ? "ARCHIVE_COMPANY" : "UPDATE_COMPANY";
await log({ userId: user.id, action, entity: "Company", entityId: params.id, request });
return Response.json({ ok: true });
}
// PUT
const body = await request.json();
const parsed = companySchema.safeParse(body);
const parsed = companyUpdateSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.company.update({ where: { id: params.id }, data: parsed.data });
await log({ userId: user.id, action: "UPDATE_COMPANY", entity: "Company", entityId: params.id, request });
return Response.json(updated);
}
+24 -19
View File
@@ -1,26 +1,11 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const companySchema = z.object({
name: z.string().min(1),
legalForm: z.string().optional(),
taxId: z.string().optional(),
address: z.string().min(1),
zip: z.string().min(1),
city: z.string().min(1),
country: z.string().optional().default("DE"),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
website: z.string().optional(),
bankIban: z.string().optional(),
bankBic: z.string().optional(),
bankName: z.string().optional(),
invoicePrefix: z.string().optional().default("RE"),
kleinunternehmer: z.boolean().optional().default(false),
});
import { log } from "@/lib/logger.server";
import { logApiError } from "@/lib/error-logger.server";
import { companySchema } from "@/lib/schemas";
export async function loader({ request }: { request: Request }) {
try {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
@@ -31,15 +16,25 @@ export async function loader({ request }: { request: Request }) {
});
return Response.json(companies);
} catch (error) {
logApiError(error, {
request,
endpoint: "/api/companies",
statusCode: 500,
});
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function action({ request }: { request: Request }) {
try {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json();
const parsed = companySchema.safeParse(body);
if (!parsed.success) {
console.warn("[CompanyAPI] Validation failed:", parsed.error.issues);
return Response.json({ error: parsed.error.issues }, { status: 400 });
}
@@ -47,5 +42,15 @@ export async function action({ request }: { request: Request }) {
data: { ...parsed.data, userId: user.id },
});
await log({ userId: user.id, action: "CREATE_COMPANY", entity: "Company", entityId: company.id, request });
return Response.json(company, { status: 201 });
} catch (error) {
logApiError(error, {
request,
endpoint: "/api/companies",
statusCode: 500,
});
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
+6 -14
View File
@@ -1,17 +1,7 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const customerSchema = z.object({
name: z.string().min(1),
taxId: z.string().optional(),
address: z.string().min(1),
zip: z.string().min(1),
city: z.string().min(1),
country: z.string().optional(),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
});
import { log } from "@/lib/logger.server";
import { customerUpdateSchema } from "@/lib/schemas";
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
@@ -35,15 +25,17 @@ export async function action({ request, params }: { request: Request; params: {
if (!customer) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.customer.delete({ where: { id: params.id } });
await prisma.customer.delete({ where: { id: params.id, company: { userId: user.id } } });
await log({ userId: user.id, action: "DELETE_CUSTOMER", entity: "Customer", entityId: params.id, request });
return Response.json({ ok: true });
}
// PUT
const body = await request.json();
const parsed = customerSchema.safeParse(body);
const parsed = customerUpdateSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.customer.update({ where: { id: params.id }, data: parsed.data });
await log({ userId: user.id, action: "UPDATE_CUSTOMER", entity: "Customer", entityId: params.id, request });
return Response.json(updated);
}
+3 -13
View File
@@ -1,18 +1,7 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const customerSchema = z.object({
companyId: z.string().min(1),
name: z.string().min(1),
taxId: z.string().optional(),
address: z.string().min(1),
zip: z.string().min(1),
city: z.string().min(1),
country: z.string().optional().default("DE"),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
});
import { log } from "@/lib/logger.server";
import { customerSchema } from "@/lib/schemas";
export async function action({ request }: { request: Request }) {
const user = await getApiUser(request);
@@ -28,5 +17,6 @@ export async function action({ request }: { request: Request }) {
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const customer = await prisma.customer.create({ data: parsed.data });
await log({ userId: user.id, action: "CREATE_CUSTOMER", entity: "Customer", entityId: customer.id, request });
return Response.json(customer, { status: 201 });
}
+61
View File
@@ -0,0 +1,61 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const updateSchema = z.object({
kategorie: z.string().min(1),
betrag: z.number().positive(),
steuersatz: z.number().min(0).default(0),
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
datum: z.string().min(1),
beschreibung: z.string().optional(),
belegUrl: z.string().optional(),
});
/**
* Handles an API request to update or delete a einnahme (Buchung).
*
* @param {Request} request - The request object.
* @param {Object} params - The route parameters.
* @param {string} params.id - The id of the Buchung (einnahme) to update or delete.
*
* @returns {Promise<Response>} - A promise resolving to a Response object.
*/
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const buchung = await prisma.buchung.findFirst({
where: { id: params.id, company: { userId: user.id }, type: "EINLAGE", isBusinessRecord: true },
});
if (!buchung) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.buchung.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}
const body = await request.json();
const parsed = updateSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.buchung.update({
where: { id: params.id },
data: {
kategorie: parsed.data.kategorie,
amount: parsed.data.betrag,
steuersatz: parsed.data.steuersatz,
zahlungsart: parsed.data.zahlungsart,
account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK",
date: new Date(parsed.data.datum),
description: parsed.data.beschreibung,
belegUrl: parsed.data.belegUrl || null,
},
});
return Response.json({
...updated,
amount: Number(updated.amount),
date: updated.date.toISOString(),
});
}
+118
View File
@@ -0,0 +1,118 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { log } from "@/lib/logger.server";
import { writeFile, mkdir, unlink } from "node:fs/promises";
import { join, resolve } from "node:path";
const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
const ALLOWED_MIME: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"application/pdf": "pdf",
};
/** Absolute path to the document storage root (configurable via env). */
function storageRoot(): string {
return resolve(process.env.BELEG_STORAGE_PATH ?? "data/documents");
}
/** belegUrl format stored in DB: "beleg:{userId}/{filename}" */
function parseBelegPath(belegUrl: string | null): string | null {
if (!belegUrl?.startsWith("beleg:")) return null;
return belegUrl.slice("beleg:".length); // "{userId}/{filename}"
}
async function removeUploadedFile(belegUrl: string | null) {
const rel = parseBelegPath(belegUrl);
if (!rel) return;
try {
await unlink(join(storageRoot(), rel));
} catch {
// File may already be gone
}
}
export async function action({
request,
params,
}: {
request: Request;
params: { id: string };
}) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const buchung = await prisma.buchung.findFirst({
where: { id: params.id, company: { userId: user.id }, isBusinessRecord: true },
select: { id: true, companyId: true, belegUrl: true },
});
if (!buchung) return Response.json({ error: "Not found" }, { status: 404 });
// DELETE — remove beleg
if (request.method === "DELETE") {
await removeUploadedFile(buchung.belegUrl);
await prisma.buchung.update({ where: { id: params.id }, data: { belegUrl: null } });
await log({
userId: user.id,
action: "DELETE_BELEG",
entity: "Buchung",
entityId: params.id,
request,
});
return Response.json({ ok: true });
}
if (request.method !== "POST") {
return Response.json({ error: "Method not allowed" }, { status: 405 });
}
// POST — upload new beleg
let formData: FormData;
try {
formData = await request.formData();
} catch {
return Response.json({ error: "Ungültige Formulardaten" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File) || file.size === 0) {
return Response.json({ error: "Keine Datei angegeben" }, { status: 400 });
}
if (file.size > MAX_SIZE_BYTES) {
return Response.json({ error: "Datei zu groß (max. 10 MB)" }, { status: 413 });
}
const ext = ALLOWED_MIME[file.type];
if (!ext) {
return Response.json(
{ error: "Dateityp nicht erlaubt (erlaubt: PDF, JPG, PNG, WebP, GIF)" },
{ status: 415 }
);
}
// Replace old file if present
await removeUploadedFile(buchung.belegUrl);
const safeName = `${buchung.id}-${Date.now()}.${ext}`;
const userDir = join(storageRoot(), user.id);
await mkdir(userDir, { recursive: true });
await writeFile(join(userDir, safeName), Buffer.from(await file.arrayBuffer()));
// Store as "beleg:{userId}/{storedName}|{originalName}"
// storedName is used for serving; originalName is preserved for display
const originalName = file.name;
const belegUrl = `beleg:${user.id}/${safeName}|${originalName}`;
await prisma.buchung.update({ where: { id: params.id }, data: { belegUrl } });
await log({
userId: user.id,
action: "UPLOAD_BELEG",
entity: "Buchung",
entityId: params.id,
metadata: { filename: file.name, size: file.size, type: file.type },
request,
});
return Response.json({ belegUrl, originalName: file.name, size: file.size });
}
+121
View File
@@ -0,0 +1,121 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { z } from "zod";
const createSchema = z.object({
companyId: z.string().min(1),
kategorie: z.string().min(1),
betrag: z.number().positive(),
steuersatz: z.number().min(0).default(0),
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
datum: z.string().min(1),
beschreibung: z.string().optional(),
belegUrl: z.string().optional(),
});
/**
* Loads the data for the EinnahmenPage.
*
* Requires a companyId search parameter. If year is provided, filters einnahmen for the given year.
*
* Returns a list of einnahmen (Buchungen with isBusinessRecord=true, type=EINLAGE) as a JSON object.
*
* If the request is unauthorized, returns a 401 response with an error message.
* If the request body is invalid, returns a 400 response with an error message containing the validation errors.
* If the company is not found, returns a 404 response with an error message.
*/
export async function loader({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(request.url);
const companyId = searchParams.get("companyId");
const year = searchParams.get("year") ? parseInt(searchParams.get("year")!) : null;
if (!companyId) return Response.json({ error: "companyId required" }, { status: 400 });
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const einnahmen = await prisma.buchung.findMany({
where: {
companyId,
type: "EINLAGE",
isBusinessRecord: true,
...(year ? {
date: {
gte: new Date(`${year}-01-01`),
lt: new Date(`${year + 1}-01-01`),
},
} : {}),
},
orderBy: { date: "asc" },
});
return Response.json(
einnahmen.map((e) => ({
...e,
amount: Number(e.amount),
date: e.date.toISOString(),
}))
);
}
/**
* Creates a new einnahme (Buchung) for a given company.
*
* Requires a JSON object in the request body with the following shape:
* {
* companyId: string,
* kategorie: string (BuchungKategorie name),
* betrag: number,
* steuersatz: number,
* zahlungsart: "KASSE" | "BANK",
* datum: string,
* beschreibung: string,
* }
*
* Returns the created Buchung as a JSON object.
*
* If the request is unauthorized, returns a 401 response with an error message.
* If the request body is invalid, returns a 400 response with an error message containing the validation errors.
* If the company is not found, returns a 404 response with an error message.
*/
export async function action({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const company = await prisma.company.findFirst({
where: { id: parsed.data.companyId, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const einnahme = await prisma.buchung.create({
data: {
companyId: parsed.data.companyId,
account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK",
type: "EINLAGE",
amount: parsed.data.betrag,
date: new Date(parsed.data.datum),
description: parsed.data.beschreibung,
kategorie: parsed.data.kategorie,
steuersatz: parsed.data.steuersatz,
zahlungsart: parsed.data.zahlungsart,
isBusinessRecord: true,
belegUrl: parsed.data.belegUrl || null,
},
});
return Response.json(
{
...einnahme,
amount: Number(einnahme.amount),
date: einnahme.date.toISOString(),
},
{ status: 201 }
);
}
+177 -36
View File
@@ -1,8 +1,12 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
import { generateInvoiceNumber } from "@/lib/invoice-number.server";
import { calcItemAmounts, calcInvoiceTotals, calcItemAmountsKleinunternehmer } from "@/lib/tax";
import { log } from "@/lib/logger.server";
import { InvoiceStatus } from "@prisma/client";
import { z } from "zod";
import { invoiceUpdateSchema, invoiceStatusSchema, invoiceItemSchema } from "@/lib/schemas";
import { writeFile, mkdir } from "node:fs/promises";
import { join, resolve } from "node:path";
async function getInvoice(id: string, userId: string) {
return prisma.invoice.findFirst({
@@ -11,6 +15,39 @@ async function getInvoice(id: string, userId: string) {
});
}
/** Storage root for documents */
function storageRoot(): string {
return resolve(process.env.BELEG_STORAGE_PATH ?? "data/documents");
}
/** Generate and save invoice PDF as beleg (receipt) */
async function generateAndSaveInvoicePDF(invoice: Awaited<ReturnType<typeof getInvoice>>, userId: string): Promise<string | null> {
if (!invoice) return null;
try {
const { renderToBuffer } = await import("@react-pdf/renderer");
const React = (await import("react")).default;
const { InvoicePDFDocument } = await import("@/components/invoice/invoice-pdf");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const element = React.createElement(InvoicePDFDocument as any, { invoice }) as any;
const buffer = await renderToBuffer(element);
// Save to storage
const safeName = `${invoice.id}-${Date.now()}.pdf`;
const userDir = join(storageRoot(), userId);
await mkdir(userDir, { recursive: true });
await writeFile(join(userDir, safeName), Buffer.from(buffer));
// Return as "beleg:{userId}/{storedName}|{originalName}"
const originalName = `rechnung-${invoice.number ?? invoice.id}.pdf`;
return `beleg:${userId}/${safeName}|${originalName}`;
} catch {
console.error("Failed to generate invoice PDF");
return null;
}
}
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
@@ -21,32 +58,7 @@ export async function loader({ request, params }: { request: Request; params: {
return Response.json(invoice);
}
const statusSchema = z.object({ status: z.nativeEnum(InvoiceStatus) });
const itemSchema = z.object({
position: z.number().int(),
description: z.string().min(1),
quantity: z.number().positive(),
unit: z.string().optional(),
unitPrice: z.number(),
taxRate: z.number(),
netAmount: z.number(),
taxAmount: z.number(),
grossAmount: z.number(),
});
const updateSchema = z.object({
customerId: z.string().min(1),
issueDate: z.string(),
deliveryDate: z.string().optional(),
dueDate: z.string(),
notes: z.string().optional(),
kleinunternehmer: z.boolean().optional().default(false),
items: z.array(itemSchema).min(1),
netTotal: z.number(),
taxTotal: z.number(),
grossTotal: z.number(),
});
const statusSchema = invoiceStatusSchema;
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
@@ -57,10 +69,24 @@ export async function action({ request, params }: { request: Request; params: {
if (request.method === "PUT") {
const body = await request.json();
const parsed = updateSchema.safeParse(body);
const parsed = invoiceUpdateSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const { items, ...invoiceData } = parsed.data;
const { items, kleinunternehmer, ...invoiceData } = parsed.data;
// Use provided kleinunternehmer or fall back to company setting
const isKleinunternehmer = kleinunternehmer ?? invoice.company.kleinunternehmer;
// Server-side recalculation of all amounts
const recalculatedItems = items.map(item => ({
...item,
...(isKleinunternehmer
? calcItemAmountsKleinunternehmer(item.quantity, item.unitPrice)
: calcItemAmounts(item.quantity, item.unitPrice, item.taxRate)),
}));
const totals = calcInvoiceTotals(recalculatedItems);
const updated = await prisma.$transaction(async (tx) => {
await tx.invoiceItem.deleteMany({ where: { invoiceId: params.id } });
return tx.invoice.update({
@@ -71,14 +97,31 @@ export async function action({ request, params }: { request: Request; params: {
deliveryDate: invoiceData.deliveryDate ? new Date(invoiceData.deliveryDate) : null,
dueDate: new Date(invoiceData.dueDate),
notes: invoiceData.notes ?? null,
kleinunternehmer: invoiceData.kleinunternehmer,
netTotal: invoiceData.netTotal,
taxTotal: invoiceData.taxTotal,
grossTotal: invoiceData.grossTotal,
items: { create: items },
kleinunternehmer: isKleinunternehmer,
netTotal: totals.netTotal,
taxTotal: totals.taxTotal,
grossTotal: totals.grossTotal,
items: { create: recalculatedItems },
},
include: { items: true, customer: true, company: true },
});
});
await log({
userId: user.id,
action: "UPDATE_INVOICE",
entity: "Invoice",
entityId: params.id,
metadata: {
customerId: invoiceData.customerId,
oldGrossTotal: invoice.grossTotal.toString(),
newGrossTotal: totals.grossTotal.toString(),
itemCount: recalculatedItems.length,
kleinunternehmer: isKleinunternehmer,
},
request,
});
return Response.json(updated);
}
@@ -92,30 +135,128 @@ export async function action({ request, params }: { request: Request; params: {
);
}
await prisma.invoice.delete({ where: { id: params.id } });
await log({
userId: user.id,
action: "DELETE_INVOICE",
entity: "Invoice",
entityId: params.id,
metadata: {
status: invoice.status,
grossTotal: invoice.grossTotal.toString(),
number: invoice.number,
},
request,
});
return Response.json({ ok: true });
}
// PATCH
// PATCH Status change with validation
const body = await request.json();
const parsed = statusSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const newStatus = parsed.data.status;
const oldStatus = invoice.status;
// Validate status transitions
const validTransitions: Record<InvoiceStatus, InvoiceStatus[]> = {
DRAFT: ["SENT", "CANCELLED", "DELETED"],
SENT: ["PAID", "CANCELLED", "DRAFT", "DELETED"],
PAID: ["CANCELLED", "DELETED"],
CANCELLED: ["DRAFT", "DELETED"],
DELETED: ["DRAFT"],
};
if (!validTransitions[oldStatus]?.includes(newStatus)) {
return Response.json(
{ error: `Ungültiger Statuswechsel von ${oldStatus} zu ${newStatus}` },
{ status: 400 }
);
}
let numberUpdate: string | null | undefined = undefined;
if (newStatus === "DELETED") {
numberUpdate = null;
} else if (invoice.status === "DELETED") {
} else if (oldStatus === "DELETED") {
numberUpdate = await generateInvoiceNumber(invoice.companyId);
}
// Handle Buchung sync: Create when PAID, delete when unpaying
if (newStatus === "PAID" && oldStatus !== "PAID") {
// Generate and save invoice PDF as beleg
const belegUrl = await generateAndSaveInvoicePDF(invoice, user.id);
// Calculate weighted average tax rate (kann mehrere Items mit unterschiedlichen Steuersätzen geben)
let averageTaxRate = 0;
if (invoice.taxTotal > 0 && invoice.netTotal > 0) {
// steuersatz = (taxTotal / netTotal) * 100
averageTaxRate = Math.round((invoice.taxTotal / invoice.netTotal) * 100);
}
// Create a Buchung for the invoice payment
const buchung = await prisma.buchung.create({
data: {
companyId: invoice.companyId,
date: invoice.issueDate,
account: "BANK",
type: "EINLAGE",
amount: invoice.grossTotal,
description: `Rechnung ${invoice.number}`,
kategorie: "Rechnungseinnahme",
isBusinessRecord: true,
steuersatz: invoice.kleinunternehmer ? 0 : averageTaxRate, // 0 for Kleinunternehmer
belegUrl: belegUrl, // Attach the generated invoice PDF
},
});
// Update invoice with buchungId
const updated = await prisma.invoice.update({
where: { id: params.id },
data: {
status: newStatus,
buchungId: buchung.id,
deletedAt: null,
...(numberUpdate !== undefined && { number: numberUpdate }),
},
include: { items: true, customer: true, company: true },
});
await log({
userId: user.id,
action: "UPDATE_INVOICE_STATUS",
entity: "Invoice",
entityId: params.id,
metadata: { oldStatus, newStatus, buchungId: buchung.id, belegUrl, steuersatz: averageTaxRate },
request,
});
return Response.json(updated);
}
if (newStatus !== "PAID" && oldStatus === "PAID" && invoice.buchungId) {
// Delete the linked Buchung when unpaying
await prisma.buchung.delete({ where: { id: invoice.buchungId } });
}
const updated = await prisma.invoice.update({
where: { id: params.id },
data: {
status: newStatus,
buchungId: newStatus === "PAID" ? invoice.buchungId : null,
deletedAt: newStatus === "DELETED" ? new Date() : null,
...(numberUpdate !== undefined && { number: numberUpdate }),
},
include: { items: true, customer: true, company: true },
});
await log({
userId: user.id,
action: "UPDATE_INVOICE_STATUS",
entity: "Invoice",
entityId: params.id,
metadata: { oldStatus, newStatus },
request,
});
return Response.json(updated);
}

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