Compare commits
35 Commits
40a2764dd0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f4005f5d4 | |||
| 0547f6a41c | |||
| 28674fb023 | |||
| b640cfdb74 | |||
| d076fba0c0 | |||
| f155a7952a | |||
| 0e62f0f80b | |||
| 29619658fc | |||
| db953b1e28 | |||
| 586d5b2cc8 | |||
| 38c8304336 | |||
| b22e5baa5c | |||
| c3e7a97c8a | |||
| fab53fc76e | |||
| f93eb0480a | |||
| ac9912fa81 | |||
| d56144b39c | |||
| f67da67abd | |||
| e526e7fd2d | |||
| c412388877 | |||
| 73ad126052 | |||
| 662e565116 | |||
| 80791e4aa7 | |||
| 8d5f69aaab | |||
| 7af907a801 | |||
| ad80688b8b | |||
| f10a79471e | |||
| 1ffbcf237c | |||
| 9e7c85c2b3 | |||
| 1ec15600b5 | |||
| d582c748a2 | |||
| 6d8c4b615f | |||
| 1bbeaf2c34 | |||
| c6dc22c859 | |||
| 5ac9e269e3 |
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 |
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--browser", "chromium"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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]
|
||||
@@ -1,9 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import "react-router";
|
||||
|
||||
declare module "react-router" {
|
||||
interface Future {
|
||||
v8_middleware: false
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"];
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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`)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
================================================================================
|
||||
*/
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user