feat: Initial implementation of Annas Rechnungsmanager
Full-stack German accounting & invoice management web application: - Multi-company management (Mandantenverwaltung) with full CRUD - Invoice creation with dynamic line items and automatic tax calculation - Sequential invoice numbering per company (RE-2024-001 format) - §14 UStG compliant PDF invoice generation via @react-pdf/renderer - Customer management (Kundenverwaltung) per company - Tax reports: quarterly USt-Voranmeldung and monthly revenue overview - Email/password authentication via NextAuth.js v5 - Responsive, modern UI with Tailwind CSS and custom shadcn/ui components - Prisma v5 ORM with MySQL/MariaDB schema + demo seed data Stack: Next.js 14 (App Router) · TypeScript · Prisma/MySQL · NextAuth.js https://claude.ai/code/session_01FN53KKxo5ebrGwqFhxzkT9
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
DATABASE_URL="mysql://user:password@localhost:3306/annas_rechnungsmanager"
|
||||||
|
AUTH_SECRET="your-random-secret-here"
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Annas Rechnungsmanager
|
||||||
|
|
||||||
|
Buchhaltungs- und Rechnungsverwaltungssystem für Mandanten.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Mandantenverwaltung** — Mehrere Unternehmen verwalten
|
||||||
|
- **Rechnungen** — Erstellen, verwalten, als PDF exportieren (§14 UStG konform)
|
||||||
|
- **Kundenverwaltung** — Kundenstammdaten pro Mandant
|
||||||
|
- **Steuerberichte** — USt-Voranmeldung, monatliche & quartalsweise Auswertungen
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Next.js 14** (App Router) + TypeScript
|
||||||
|
- **MySQL / MariaDB** via Prisma ORM
|
||||||
|
- **NextAuth.js v5** (Email/Passwort-Login)
|
||||||
|
- **Tailwind CSS** + shadcn/ui
|
||||||
|
- **@react-pdf/renderer** für PDF-Generierung
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Voraussetzungen
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- MySQL oder MariaDB
|
||||||
|
|
||||||
|
### 2. Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Datenbank konfigurieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# DATABASE_URL in .env anpassen
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Datenbank einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate dev --name init
|
||||||
|
npx prisma db seed
|
||||||
|
```
|
||||||
|
|
||||||
|
**Demo-Zugangsdaten:** `anna@example.de` / `demo123`
|
||||||
|
|
||||||
|
### 5. Entwicklungsserver starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Öffne [http://localhost:3000](http://localhost:3000)
|
||||||
|
|
||||||
|
## Datenbank-Kommandos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate # Migrationen ausführen
|
||||||
|
npm run db:seed # Demo-Daten einspielen
|
||||||
|
npm run db:studio # Prisma Studio öffnen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rechnungs-Compliance (§14 UStG)
|
||||||
|
|
||||||
|
Alle PDFs enthalten die gesetzlich vorgeschriebenen Pflichtangaben:
|
||||||
|
- Name & Anschrift Rechnungssteller und -empfänger
|
||||||
|
- Steuernummer / USt-IdNr. des Ausstellers
|
||||||
|
- Rechnungsdatum & Rechnungsnummer (fortlaufend)
|
||||||
|
- Leistungsdatum / Lieferdatum
|
||||||
|
- Leistungsbeschreibung
|
||||||
|
- Nettobetrag, USt-Satz, USt-Betrag, Bruttobetrag
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+8518
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "annas-app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "npx ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"next": "16.1.6",
|
||||||
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-hook-form": "^7.71.2",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
name String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
companies Company[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Company {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
legalForm String?
|
||||||
|
taxId String?
|
||||||
|
vatId String?
|
||||||
|
address String
|
||||||
|
zip String
|
||||||
|
city String
|
||||||
|
country String @default("DE")
|
||||||
|
email String?
|
||||||
|
phone String?
|
||||||
|
website String?
|
||||||
|
bankIban String?
|
||||||
|
bankBic String?
|
||||||
|
bankName String?
|
||||||
|
invoicePrefix String @default("RE")
|
||||||
|
invoiceSequence Int @default(0)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
customers Customer[]
|
||||||
|
invoices Invoice[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("companies")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Customer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
companyId String
|
||||||
|
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
vatId String?
|
||||||
|
taxId String?
|
||||||
|
address String
|
||||||
|
zip String
|
||||||
|
city String
|
||||||
|
country String @default("DE")
|
||||||
|
email String?
|
||||||
|
phone String?
|
||||||
|
invoices Invoice[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("customers")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Invoice {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
number String
|
||||||
|
companyId String
|
||||||
|
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||||
|
customerId String
|
||||||
|
customer Customer @relation(fields: [customerId], references: [id])
|
||||||
|
issueDate DateTime
|
||||||
|
deliveryDate DateTime?
|
||||||
|
dueDate DateTime
|
||||||
|
status InvoiceStatus @default(DRAFT)
|
||||||
|
notes String? @db.Text
|
||||||
|
items InvoiceItem[]
|
||||||
|
netTotal Decimal @db.Decimal(10, 2)
|
||||||
|
taxTotal Decimal @db.Decimal(10, 2)
|
||||||
|
grossTotal Decimal @db.Decimal(10, 2)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([companyId, number])
|
||||||
|
@@map("invoices")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InvoiceStatus {
|
||||||
|
DRAFT
|
||||||
|
SENT
|
||||||
|
PAID
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
model InvoiceItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
invoiceId String
|
||||||
|
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
|
||||||
|
position Int
|
||||||
|
description String @db.Text
|
||||||
|
quantity Decimal @db.Decimal(10, 3)
|
||||||
|
unit String?
|
||||||
|
unitPrice Decimal @db.Decimal(10, 2)
|
||||||
|
taxRate Decimal @db.Decimal(5, 2)
|
||||||
|
netAmount Decimal @db.Decimal(10, 2)
|
||||||
|
taxAmount Decimal @db.Decimal(10, 2)
|
||||||
|
grossAmount Decimal @db.Decimal(10, 2)
|
||||||
|
|
||||||
|
@@map("invoice_items")
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("Seeding database...");
|
||||||
|
|
||||||
|
// Create demo user
|
||||||
|
const passwordHash = await bcrypt.hash("demo123", 12);
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { email: "anna@example.de" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: "anna@example.de",
|
||||||
|
passwordHash,
|
||||||
|
name: "Anna Musterfrau",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✓ User created: ${user.email}`);
|
||||||
|
|
||||||
|
// Create demo company
|
||||||
|
const company = await prisma.company.upsert({
|
||||||
|
where: { id: "demo-company-1" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: "demo-company-1",
|
||||||
|
name: "Muster GmbH",
|
||||||
|
legalForm: "GmbH",
|
||||||
|
taxId: "123/456/78901",
|
||||||
|
vatId: "DE123456789",
|
||||||
|
address: "Musterstraße 1",
|
||||||
|
zip: "10115",
|
||||||
|
city: "Berlin",
|
||||||
|
email: "info@muster-gmbh.de",
|
||||||
|
phone: "+49 30 12345678",
|
||||||
|
bankName: "Musterbank",
|
||||||
|
bankIban: "DE89 3704 0044 0532 0130 00",
|
||||||
|
bankBic: "COBADEFFXXX",
|
||||||
|
invoicePrefix: "RE",
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✓ Company created: ${company.name}`);
|
||||||
|
|
||||||
|
// Create demo customer
|
||||||
|
const customer = await prisma.customer.upsert({
|
||||||
|
where: { id: "demo-customer-1" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: "demo-customer-1",
|
||||||
|
companyId: company.id,
|
||||||
|
name: "Beispiel AG",
|
||||||
|
vatId: "DE987654321",
|
||||||
|
address: "Beispielweg 5",
|
||||||
|
zip: "20095",
|
||||||
|
city: "Hamburg",
|
||||||
|
email: "kontakt@beispiel-ag.de",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✓ Customer created: ${customer.name}`);
|
||||||
|
|
||||||
|
// Create demo invoice
|
||||||
|
const invoice = await prisma.invoice.create({
|
||||||
|
data: {
|
||||||
|
number: "RE-2024-001",
|
||||||
|
companyId: company.id,
|
||||||
|
customerId: customer.id,
|
||||||
|
issueDate: new Date("2024-01-15"),
|
||||||
|
deliveryDate: new Date("2024-01-15"),
|
||||||
|
dueDate: new Date("2024-02-14"),
|
||||||
|
status: "SENT",
|
||||||
|
netTotal: 1000.0,
|
||||||
|
taxTotal: 190.0,
|
||||||
|
grossTotal: 1190.0,
|
||||||
|
items: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
description: "Buchhaltungsleistungen Januar 2024",
|
||||||
|
quantity: 10,
|
||||||
|
unit: "h",
|
||||||
|
unitPrice: 100.0,
|
||||||
|
taxRate: 19.0,
|
||||||
|
netAmount: 1000.0,
|
||||||
|
taxAmount: 190.0,
|
||||||
|
grossAmount: 1190.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✓ Invoice created: ${invoice.number}`);
|
||||||
|
|
||||||
|
// Update company sequence
|
||||||
|
await prisma.company.update({
|
||||||
|
where: { id: company.id },
|
||||||
|
data: { invoiceSequence: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n✅ Seed complete!");
|
||||||
|
console.log("Login: anna@example.de / demo123");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,3 @@
|
|||||||
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Calculator, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
const res = await signIn("credentials", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
setError("E-Mail oder Passwort falsch.");
|
||||||
|
} else {
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-blue-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-indigo-600 mb-4">
|
||||||
|
<Calculator className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Annas Rechnungsmanager</h1>
|
||||||
|
<p className="text-gray-500 mt-1">Buchhaltung & Rechnungsverwaltung</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Anmelden</CardTitle>
|
||||||
|
<CardDescription>Geben Sie Ihre Zugangsdaten ein</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">E-Mail</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="anna@example.de"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="password">Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "Anmelden..." : "Anmelden"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Users, Plus, Edit, Trash2, ChevronLeft, Mail, Phone } from "lucide-react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, "Pflichtfeld"),
|
||||||
|
vatId: z.string().optional(),
|
||||||
|
address: z.string().min(1, "Pflichtfeld"),
|
||||||
|
zip: z.string().min(1, "Pflichtfeld"),
|
||||||
|
city: z.string().min(1, "Pflichtfeld"),
|
||||||
|
country: z.string().optional(),
|
||||||
|
email: z.string().email("Ungültige E-Mail").optional().or(z.literal("")),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
});
|
||||||
|
type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
interface Customer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
vatId?: string | null | undefined;
|
||||||
|
address: string;
|
||||||
|
zip: string;
|
||||||
|
city: string;
|
||||||
|
email?: string | null | undefined;
|
||||||
|
phone?: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomerForEdit {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
vatId?: string | undefined;
|
||||||
|
address: string;
|
||||||
|
zip: string;
|
||||||
|
city: string;
|
||||||
|
email?: string | undefined;
|
||||||
|
phone?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomerForm({
|
||||||
|
defaultValues,
|
||||||
|
onSubmit,
|
||||||
|
submitLabel,
|
||||||
|
}: {
|
||||||
|
defaultValues?: Partial<FormData>;
|
||||||
|
onSubmit: (d: FormData) => Promise<void>;
|
||||||
|
submitLabel: string;
|
||||||
|
}) {
|
||||||
|
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { country: "DE", ...defaultValues },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Name *</Label>
|
||||||
|
<Input {...register("name")} placeholder="Beispiel AG" />
|
||||||
|
{errors.name && <p className="text-xs text-red-600">{errors.name.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Straße & Nr. *</Label>
|
||||||
|
<Input {...register("address")} placeholder="Musterstr. 1" />
|
||||||
|
{errors.address && <p className="text-xs text-red-600">{errors.address.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>PLZ *</Label>
|
||||||
|
<Input {...register("zip")} placeholder="10115" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Ort *</Label>
|
||||||
|
<Input {...register("city")} placeholder="Berlin" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>USt-IdNr.</Label>
|
||||||
|
<Input {...register("vatId")} placeholder="DE..." />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>E-Mail</Label>
|
||||||
|
<Input {...register("email")} type="email" placeholder="kontakt@..." />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Telefon</Label>
|
||||||
|
<Input {...register("phone")} placeholder="+49..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<Button type="submit" disabled={isSubmitting}>{isSubmitting ? "Speichern..." : submitLabel}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomersPage() {
|
||||||
|
const { id: companyId } = useParams<{ id: string }>();
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editCustomer, setEditCustomer] = useState<CustomerForEdit | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch(`/api/companies/${companyId}/customers`);
|
||||||
|
if (res.ok) setCustomers(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [companyId]);
|
||||||
|
|
||||||
|
async function handleCreate(data: FormData) {
|
||||||
|
await fetch("/api/customers", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...data, companyId }),
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEdit(data: FormData) {
|
||||||
|
if (!editCustomer) return;
|
||||||
|
await fetch(`/api/customers/${editCustomer.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
setEditCustomer(null);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(customerId: string) {
|
||||||
|
if (!confirm("Kunden wirklich löschen?")) return;
|
||||||
|
await fetch(`/api/customers/${customerId}`, { method: "DELETE" });
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={`/companies/${companyId}`}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Kunden</h1>
|
||||||
|
<p className="text-gray-500 mt-1">{customers.length} Kunden</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button><Plus className="h-4 w-4" /> Kunde anlegen</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Neuer Kunde</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<CustomerForm onSubmit={handleCreate} submitLabel="Anlegen" />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<Dialog open={!!editCustomer} onOpenChange={(o) => !o && setEditCustomer(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Kunde bearbeiten</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{editCustomer && (
|
||||||
|
<CustomerForm
|
||||||
|
defaultValues={editCustomer}
|
||||||
|
onSubmit={handleEdit}
|
||||||
|
submitLabel="Speichern"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{customers.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<Users className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500 text-sm">Noch keine Kunden angelegt</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{customers.map((customer) => (
|
||||||
|
<Card key={customer.id}>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900">{customer.name}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">{customer.address}, {customer.zip} {customer.city}</p>
|
||||||
|
{customer.vatId && <p className="text-xs text-gray-400 mt-0.5">USt-IdNr.: {customer.vatId}</p>}
|
||||||
|
<div className="flex gap-3 mt-2">
|
||||||
|
{customer.email && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Mail className="h-3 w-3" />{customer.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{customer.phone && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Phone className="h-3 w-3" />{customer.phone}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setEditCustomer({
|
||||||
|
id: customer.id,
|
||||||
|
name: customer.name,
|
||||||
|
address: customer.address,
|
||||||
|
zip: customer.zip,
|
||||||
|
city: customer.city,
|
||||||
|
vatId: customer.vatId ?? undefined,
|
||||||
|
email: customer.email ?? undefined,
|
||||||
|
phone: customer.phone ?? undefined,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => handleDelete(customer.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import { CompanyForm } from "@/components/company/company-form";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export default function EditCompanyPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [company, setCompany] = useState<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/companies/${id}`).then((r) => r.json()).then(setCompany);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
async function handleSubmit(data: Record<string, unknown>) {
|
||||||
|
const res = await fetch(`/api/companies/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (res.ok) router.push(`/companies/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!company) return <div className="p-8 text-gray-500">Lade...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={`/companies/${id}`}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Mandant bearbeiten</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Mandantendaten</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CompanyForm
|
||||||
|
defaultValues={company as Record<string, string>}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitLabel="Änderungen speichern"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
|
import { Download, CheckCircle, Send, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
invoice: {
|
||||||
|
id: string;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
companyId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoiceActions({ invoice }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function updateStatus(status: InvoiceStatus) {
|
||||||
|
setLoading(true);
|
||||||
|
await fetch(`/api/invoices/${invoice.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!confirm("Rechnung wirklich löschen?")) return;
|
||||||
|
await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" });
|
||||||
|
router.push(`/companies/${invoice.companyId}/invoices`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadPdf() {
|
||||||
|
const res = await fetch(`/api/invoices/${invoice.id}/pdf`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `rechnung-${invoice.id}.pdf`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={downloadPdf}>
|
||||||
|
<Download className="h-4 w-4" /> PDF
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{invoice.status === "DRAFT" && (
|
||||||
|
<Button size="sm" onClick={() => updateStatus(InvoiceStatus.SENT)} disabled={loading}>
|
||||||
|
<Send className="h-4 w-4" /> Als versendet markieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{invoice.status === "SENT" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
onClick={() => updateStatus(InvoiceStatus.PAID)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4" /> Als bezahlt markieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(invoice.status === "DRAFT" || invoice.status === "CANCELLED") && (
|
||||||
|
<Button variant="outline" size="sm" className="text-red-600 hover:text-red-700 hover:bg-red-50" onClick={handleDelete}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
|
||||||
|
import { InvoiceActions } from "./invoice-actions";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/tax";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export default async function InvoiceDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; invoiceId: string }>;
|
||||||
|
}) {
|
||||||
|
const { id, invoiceId } = await params;
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
const invoice = await prisma.invoice.findFirst({
|
||||||
|
where: { id: invoiceId, companyId: id, company: { userId: session!.user!.id! } },
|
||||||
|
include: {
|
||||||
|
items: { orderBy: { position: "asc" } },
|
||||||
|
customer: true,
|
||||||
|
company: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invoice) notFound();
|
||||||
|
|
||||||
|
// Group items by tax rate for totals block
|
||||||
|
const taxGroups = invoice.items.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
const rate = Number(item.taxRate);
|
||||||
|
if (!acc[rate]) acc[rate] = { net: 0, tax: 0 };
|
||||||
|
acc[rate].net += Number(item.netAmount);
|
||||||
|
acc[rate].tax += Number(item.taxAmount);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<number, { net: number; tax: number }>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={`/companies/${id}/invoices`}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" /> Zurück zu Rechnungen
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{invoice.number}</h1>
|
||||||
|
<InvoiceStatusBadge status={invoice.status} />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{invoice.customer.name} · {formatDate(invoice.issueDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<InvoiceActions invoice={{ id: invoice.id, status: invoice.status, companyId: id }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Invoice document preview */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
{/* Sender & Recipient */}
|
||||||
|
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Absender</p>
|
||||||
|
<p className="font-semibold text-gray-900">{invoice.company.name}</p>
|
||||||
|
{invoice.company.legalForm && <p className="text-sm text-gray-600">{invoice.company.legalForm}</p>}
|
||||||
|
<p className="text-sm text-gray-600">{invoice.company.address}</p>
|
||||||
|
<p className="text-sm text-gray-600">{invoice.company.zip} {invoice.company.city}</p>
|
||||||
|
{invoice.company.taxId && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">St.-Nr.: {invoice.company.taxId}</p>
|
||||||
|
)}
|
||||||
|
{invoice.company.vatId && (
|
||||||
|
<p className="text-xs text-gray-500">USt-IdNr.: {invoice.company.vatId}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Rechnungsempfänger</p>
|
||||||
|
<p className="font-semibold text-gray-900">{invoice.customer.name}</p>
|
||||||
|
<p className="text-sm text-gray-600">{invoice.customer.address}</p>
|
||||||
|
<p className="text-sm text-gray-600">{invoice.customer.zip} {invoice.customer.city}</p>
|
||||||
|
{invoice.customer.vatId && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">USt-IdNr.: {invoice.customer.vatId}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-8 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Rechnungsnummer</p>
|
||||||
|
<p className="text-sm font-semibold text-gray-900">{invoice.number}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Rechnungsdatum</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{formatDate(invoice.issueDate)}</p>
|
||||||
|
</div>
|
||||||
|
{invoice.deliveryDate && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Leistungsdatum</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{formatDate(invoice.deliveryDate)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Fällig am</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{formatDate(invoice.dueDate)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items table */}
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden mb-6">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<th className="text-left px-4 py-2.5 font-medium text-gray-600 w-8">#</th>
|
||||||
|
<th className="text-left px-4 py-2.5 font-medium text-gray-600">Beschreibung</th>
|
||||||
|
<th className="text-right px-4 py-2.5 font-medium text-gray-600">Menge</th>
|
||||||
|
<th className="text-right px-4 py-2.5 font-medium text-gray-600">EP (netto)</th>
|
||||||
|
<th className="text-right px-4 py-2.5 font-medium text-gray-600">MwSt.</th>
|
||||||
|
<th className="text-right px-4 py-2.5 font-medium text-gray-600">Betrag (brutto)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{invoice.items.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 text-gray-500">{item.position}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-900">{item.description}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-700">
|
||||||
|
{Number(item.quantity)} {item.unit && <span className="text-gray-500">{item.unit}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-700">{formatCurrency(Number(item.unitPrice))}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-700">{Number(item.taxRate)}%</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium text-gray-900">{formatCurrency(Number(item.grossAmount))}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="w-72 space-y-1.5">
|
||||||
|
<div className="flex justify-between text-sm text-gray-600">
|
||||||
|
<span>Nettobetrag</span>
|
||||||
|
<span>{formatCurrency(Number(invoice.netTotal))}</span>
|
||||||
|
</div>
|
||||||
|
{Object.entries(taxGroups).map(([rate, { net, tax }]) => (
|
||||||
|
<div key={rate} className="flex justify-between text-sm text-gray-600">
|
||||||
|
<span>MwSt. {rate}% auf {formatCurrency(net)}</span>
|
||||||
|
<span>{formatCurrency(tax)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex justify-between text-base font-bold text-gray-900 border-t border-gray-300 pt-2">
|
||||||
|
<span>Gesamtbetrag (brutto)</span>
|
||||||
|
<span>{formatCurrency(Number(invoice.grossTotal))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invoice.notes && (
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Hinweise</p>
|
||||||
|
<p className="text-sm text-gray-700 whitespace-pre-wrap">{invoice.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bank details */}
|
||||||
|
{invoice.company.bankIban && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Bankverbindung: {invoice.company.bankName && `${invoice.company.bankName} · `}
|
||||||
|
IBAN: {invoice.company.bankIban}
|
||||||
|
{invoice.company.bankBic && ` · BIC: ${invoice.company.bankBic}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Zusammenfassung</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Netto</p>
|
||||||
|
<p className="font-medium text-gray-900">{formatCurrency(Number(invoice.netTotal))}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">MwSt.</p>
|
||||||
|
<p className="font-medium text-gray-900">{formatCurrency(Number(invoice.taxTotal))}</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-gray-200 pt-2">
|
||||||
|
<p className="text-xs text-gray-500">Brutto</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{formatCurrency(Number(invoice.grossTotal))}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { InvoiceForm } from "@/components/invoice/invoice-form";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
customers: { id: string; name: string }[];
|
||||||
|
companyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoiceFormClient({ customers, companyId }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleSubmit(data: Record<string, unknown>) {
|
||||||
|
const res = await fetch("/api/invoices", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const invoice = await res.json();
|
||||||
|
router.push(`/companies/${companyId}/invoices/${invoice.id}`);
|
||||||
|
} else {
|
||||||
|
alert("Fehler beim Erstellen der Rechnung.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <InvoiceForm customers={customers} companyId={companyId} onSubmit={handleSubmit} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { InvoiceFormClient } from "./invoice-form-client";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export default async function NewInvoicePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
const company = await prisma.company.findFirst({
|
||||||
|
where: { id, userId: session!.user!.id! },
|
||||||
|
});
|
||||||
|
if (!company) notFound();
|
||||||
|
|
||||||
|
const customers = await prisma.customer.findMany({
|
||||||
|
where: { companyId: id },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (customers.length === 0) {
|
||||||
|
redirect(`/companies/${id}/customers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={`/companies/${id}/invoices`}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" /> Zurück zu Rechnungen
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Neue Rechnung</h1>
|
||||||
|
<p className="text-gray-500 mt-1">Für Mandant: {company.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Rechnungsdaten</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<InvoiceFormClient customers={customers} companyId={id} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/tax";
|
||||||
|
import { Plus, FileText, ChevronLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export default async function InvoicesPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
const company = await prisma.company.findFirst({
|
||||||
|
where: { id, userId: session!.user!.id! },
|
||||||
|
});
|
||||||
|
if (!company) notFound();
|
||||||
|
|
||||||
|
const invoices = await prisma.invoice.findMany({
|
||||||
|
where: { companyId: id },
|
||||||
|
include: { customer: { select: { name: true } } },
|
||||||
|
orderBy: { issueDate: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={`/companies/${id}`}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" /> {company.name}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Rechnungen</h1>
|
||||||
|
<p className="text-gray-500 mt-1">{invoices.length} Rechnungen für {company.name}</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/companies/${id}/invoices/new`}>
|
||||||
|
<Plus className="h-4 w-4" /> Neue Rechnung
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invoices.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-16 text-center">
|
||||||
|
<FileText className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||||
|
<h3 className="font-semibold text-gray-700 mb-2">Noch keine Rechnungen</h3>
|
||||||
|
<p className="text-gray-500 mb-6 text-sm">Erstellen Sie die erste Rechnung für diesen Mandanten.</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/companies/${id}/invoices/new`}>
|
||||||
|
<Plus className="h-4 w-4" /> Erste Rechnung erstellen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{invoices.map((invoice) => (
|
||||||
|
<Link
|
||||||
|
key={invoice.id}
|
||||||
|
href={`/companies/${id}/invoices/${invoice.id}`}
|
||||||
|
className="flex items-center justify-between px-6 py-4 hover:bg-gray-50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2 rounded-lg bg-gray-100 group-hover:bg-indigo-50 transition-colors">
|
||||||
|
<FileText className="h-4 w-4 text-gray-500 group-hover:text-indigo-600 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{invoice.number}</p>
|
||||||
|
<p className="text-sm text-gray-500">{invoice.customer.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<p className="text-sm text-gray-500">{formatDate(invoice.issueDate)}</p>
|
||||||
|
<InvoiceStatusBadge status={invoice.status} />
|
||||||
|
<p className="font-medium text-gray-900 w-28 text-right">
|
||||||
|
{formatCurrency(Number(invoice.grossTotal))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/tax";
|
||||||
|
import {
|
||||||
|
FileText, Users, BarChart3, Plus, Edit, Building2,
|
||||||
|
Mail, Phone, CreditCard, Receipt
|
||||||
|
} from "lucide-react";
|
||||||
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
const statusLabels: Record<InvoiceStatus, string> = {
|
||||||
|
DRAFT: "Entwurf",
|
||||||
|
SENT: "Versendet",
|
||||||
|
PAID: "Bezahlt",
|
||||||
|
CANCELLED: "Storniert",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusVariants: Record<InvoiceStatus, "secondary" | "default" | "success" | "destructive" | "warning"> = {
|
||||||
|
DRAFT: "secondary",
|
||||||
|
SENT: "warning",
|
||||||
|
PAID: "success",
|
||||||
|
CANCELLED: "destructive",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CompanyPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
const company = await prisma.company.findFirst({
|
||||||
|
where: { id, userId: session!.user!.id! },
|
||||||
|
include: {
|
||||||
|
invoices: {
|
||||||
|
include: { customer: { select: { name: true } } },
|
||||||
|
orderBy: { issueDate: "desc" },
|
||||||
|
take: 5,
|
||||||
|
},
|
||||||
|
_count: { select: { invoices: true, customers: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!company) notFound();
|
||||||
|
|
||||||
|
const revenue = await prisma.invoice.aggregate({
|
||||||
|
where: { companyId: id, status: InvoiceStatus.PAID },
|
||||||
|
_sum: { grossTotal: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-8">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="p-3 rounded-xl bg-indigo-50">
|
||||||
|
<Building2 className="h-6 w-6 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{company.name}</h1>
|
||||||
|
<p className="text-gray-500 mt-0.5">
|
||||||
|
{company.legalForm && `${company.legalForm} · `}
|
||||||
|
{company.zip} {company.city}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/companies/${id}/edit`}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
Bearbeiten
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
|
||||||
|
<Link href={`/companies/${id}/invoices/new`} className="block">
|
||||||
|
<Card className="hover:border-indigo-200 hover:shadow-sm transition-all cursor-pointer">
|
||||||
|
<CardContent className="pt-4 pb-4 flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-indigo-50">
|
||||||
|
<Plus className="h-4 w-4 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Rechnung erstellen</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/companies/${id}/invoices`} className="block">
|
||||||
|
<Card className="hover:border-blue-200 hover:shadow-sm transition-all cursor-pointer">
|
||||||
|
<CardContent className="pt-4 pb-4 flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-blue-50">
|
||||||
|
<FileText className="h-4 w-4 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Rechnungen</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/companies/${id}/customers`} className="block">
|
||||||
|
<Card className="hover:border-green-200 hover:shadow-sm transition-all cursor-pointer">
|
||||||
|
<CardContent className="pt-4 pb-4 flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-green-50">
|
||||||
|
<Users className="h-4 w-4 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Kunden</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/companies/${id}/reports`} className="block">
|
||||||
|
<Card className="hover:border-purple-200 hover:shadow-sm transition-all cursor-pointer">
|
||||||
|
<CardContent className="pt-4 pb-4 flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-purple-50">
|
||||||
|
<BarChart3 className="h-4 w-4 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Berichte</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Recent Invoices */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="font-semibold text-gray-900">Letzte Rechnungen</h2>
|
||||||
|
<Link href={`/companies/${id}/invoices`} className="text-sm text-indigo-600 hover:text-indigo-700">
|
||||||
|
Alle anzeigen →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
{company.invoices.length === 0 ? (
|
||||||
|
<CardContent className="py-8 text-center text-gray-500">
|
||||||
|
<Receipt className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
||||||
|
<p className="text-sm">Noch keine Rechnungen</p>
|
||||||
|
</CardContent>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{company.invoices.map((invoice) => (
|
||||||
|
<Link
|
||||||
|
key={invoice.id}
|
||||||
|
href={`/companies/${id}/invoices/${invoice.id}`}
|
||||||
|
className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{invoice.number}</p>
|
||||||
|
<p className="text-xs text-gray-500">{invoice.customer.name} · {formatDate(invoice.issueDate)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant={statusVariants[invoice.status]}>{statusLabels[invoice.status]}</Badge>
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
{formatCurrency(Number(invoice.grossTotal))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company Info */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Steuer & Umsatz</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Bezahlt (gesamt)</p>
|
||||||
|
<p className="font-semibold text-gray-900">{formatCurrency(Number(revenue._sum.grossTotal ?? 0))}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Rechnungen</p>
|
||||||
|
<p className="font-semibold text-gray-900">{company._count.invoices}</p>
|
||||||
|
</div>
|
||||||
|
{company.taxId && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Steuernummer</p>
|
||||||
|
<p className="text-sm font-mono text-gray-900">{company.taxId}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{company.vatId && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">USt-IdNr.</p>
|
||||||
|
<p className="text-sm font-mono text-gray-900">{company.vatId}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{(company.email || company.phone) && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Kontakt</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{company.email && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<Mail className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||||
|
{company.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{company.phone && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<Phone className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||||
|
{company.phone}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{company.bankIban && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Bankverbindung</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<CreditCard className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||||
|
<span className="font-mono text-xs">{company.bankIban}</span>
|
||||||
|
</div>
|
||||||
|
{company.bankName && <p className="text-xs text-gray-500 ml-5">{company.bankName}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { formatCurrency } from "@/lib/tax";
|
||||||
|
import { ChevronLeft, TrendingUp, BarChart3 } from "lucide-react";
|
||||||
|
|
||||||
|
const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
||||||
|
|
||||||
|
interface TaxGroup {
|
||||||
|
netAmount: number;
|
||||||
|
taxAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthData {
|
||||||
|
month: number;
|
||||||
|
invoiceCount: number;
|
||||||
|
netTotal: number;
|
||||||
|
taxTotal: number;
|
||||||
|
grossTotal: number;
|
||||||
|
taxGroups: Record<string, TaxGroup>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuarterData {
|
||||||
|
quarter: number;
|
||||||
|
invoiceCount: number;
|
||||||
|
netTotal: number;
|
||||||
|
taxTotal: number;
|
||||||
|
grossTotal: number;
|
||||||
|
taxGroups: Record<string, TaxGroup>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportData {
|
||||||
|
year: number;
|
||||||
|
monthly: MonthData[];
|
||||||
|
quarterly: QuarterData[];
|
||||||
|
yearTotal: {
|
||||||
|
invoiceCount: number;
|
||||||
|
netTotal: number;
|
||||||
|
taxTotal: number;
|
||||||
|
grossTotal: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportsPage() {
|
||||||
|
const { id: companyId } = useParams<{ id: string }>();
|
||||||
|
const [year, setYear] = useState(new Date().getFullYear());
|
||||||
|
const [data, setData] = useState<ReportData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`/api/reports?companyId=${companyId}&year=${year}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => { setData(d); setLoading(false); });
|
||||||
|
}, [companyId, year]);
|
||||||
|
|
||||||
|
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={`/companies/${companyId}`}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Steuerberichte</h1>
|
||||||
|
<p className="text-gray-500 mt-1">Auswertungen für Steuererklärung und USt-Voranmeldung</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(Number(e.target.value))}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
{years.map((y) => <option key={y} value={y}>{y}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-gray-500 py-12">Lade Auswertung...</div>
|
||||||
|
) : data && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Year Summary */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Rechnungen</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{data.yearTotal.invoiceCount}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Umsatz (netto)</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{formatCurrency(data.yearTotal.netTotal)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">USt. gesamt</p>
|
||||||
|
<p className="text-2xl font-bold text-indigo-600">{formatCurrency(data.yearTotal.taxTotal)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Umsatz (brutto)</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{formatCurrency(data.yearTotal.grossTotal)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quarterly UStVA */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5 text-indigo-600" />
|
||||||
|
<CardTitle>USt-Voranmeldung (quartalsweise)</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left pb-3 text-gray-500 font-medium">Quartal</th>
|
||||||
|
<th className="text-right pb-3 text-gray-500 font-medium">Rechnungen</th>
|
||||||
|
<th className="text-right pb-3 text-gray-500 font-medium">Netto</th>
|
||||||
|
<th className="text-right pb-3 text-gray-500 font-medium">USt. 19%</th>
|
||||||
|
<th className="text-right pb-3 text-gray-500 font-medium">USt. 7%</th>
|
||||||
|
<th className="text-right pb-3 text-gray-500 font-medium">USt. gesamt</th>
|
||||||
|
<th className="text-right pb-3 text-gray-500 font-medium">Brutto</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{data.quarterly.map((q) => (
|
||||||
|
<tr key={q.quarter} className="hover:bg-gray-50">
|
||||||
|
<td className="py-3 font-medium text-gray-900">Q{q.quarter} {year}</td>
|
||||||
|
<td className="py-3 text-right text-gray-700">{q.invoiceCount}</td>
|
||||||
|
<td className="py-3 text-right text-gray-700">{formatCurrency(q.netTotal)}</td>
|
||||||
|
<td className="py-3 text-right text-gray-700">
|
||||||
|
{q.taxGroups["19"] ? formatCurrency(q.taxGroups["19"].taxAmount) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right text-gray-700">
|
||||||
|
{q.taxGroups["7"] ? formatCurrency(q.taxGroups["7"].taxAmount) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right font-medium text-indigo-700">{formatCurrency(q.taxTotal)}</td>
|
||||||
|
<td className="py-3 text-right font-semibold text-gray-900">{formatCurrency(q.grossTotal)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t-2 border-gray-300">
|
||||||
|
<td className="pt-3 font-bold text-gray-900">Gesamt {year}</td>
|
||||||
|
<td className="pt-3 text-right font-bold text-gray-900">{data.yearTotal.invoiceCount}</td>
|
||||||
|
<td className="pt-3 text-right font-bold text-gray-900">{formatCurrency(data.yearTotal.netTotal)}</td>
|
||||||
|
<td className="pt-3 text-right font-bold text-gray-900">
|
||||||
|
{formatCurrency(data.quarterly.reduce((s, q) => s + (q.taxGroups["19"]?.taxAmount ?? 0), 0))}
|
||||||
|
</td>
|
||||||
|
<td className="pt-3 text-right font-bold text-gray-900">
|
||||||
|
{formatCurrency(data.quarterly.reduce((s, q) => s + (q.taxGroups["7"]?.taxAmount ?? 0), 0))}
|
||||||
|
</td>
|
||||||
|
<td className="pt-3 text-right font-bold text-indigo-700">{formatCurrency(data.yearTotal.taxTotal)}</td>
|
||||||
|
<td className="pt-3 text-right font-bold text-gray-900">{formatCurrency(data.yearTotal.grossTotal)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Monthly breakdown */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5 text-indigo-600" />
|
||||||
|
<CardTitle>Monatliche Übersicht</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left pb-3 text-gray-500 font-medium">Monat</th>
|
||||||
|
<th className="text-right pb-3 text-gray-500 font-medium">Rechnungen</th>
|
||||||
|
<th className="text-right pb-3 text-gray-500 font-medium">Netto</th>
|
||||||
|
<th className="text-right pb-3 text-gray-500 font-medium">USt.</th>
|
||||||
|
<th className="text-right pb-3 text-gray-500 font-medium">Brutto</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{data.monthly.map((m) => (
|
||||||
|
<tr key={m.month} className={`hover:bg-gray-50 ${m.invoiceCount === 0 ? "opacity-40" : ""}`}>
|
||||||
|
<td className="py-2.5 font-medium text-gray-900">{MONTHS[m.month - 1]} {year}</td>
|
||||||
|
<td className="py-2.5 text-right text-gray-700">{m.invoiceCount || "—"}</td>
|
||||||
|
<td className="py-2.5 text-right text-gray-700">
|
||||||
|
{m.netTotal > 0 ? formatCurrency(m.netTotal) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 text-right text-indigo-700">
|
||||||
|
{m.taxTotal > 0 ? formatCurrency(m.taxTotal) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 text-right font-medium text-gray-900">
|
||||||
|
{m.grossTotal > 0 ? formatCurrency(m.grossTotal) : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { CompanyForm } from "@/components/company/company-form";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export default function NewCompanyPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleSubmit(data: Record<string, unknown>) {
|
||||||
|
const res = await fetch("/api/companies", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const company = await res.json();
|
||||||
|
router.push(`/companies/${company.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href="/companies"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" /> Zurück zu Mandanten
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Neuer Mandant</h1>
|
||||||
|
<p className="text-gray-500 mt-1">Legen Sie einen neuen Mandanten an</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Mandantendaten</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CompanyForm
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitLabel="Mandant anlegen"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Building2, Plus, FileText, Users } from "lucide-react";
|
||||||
|
|
||||||
|
export default async function CompaniesPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const companies = await prisma.company.findMany({
|
||||||
|
where: { userId: session!.user!.id! },
|
||||||
|
include: { _count: { select: { invoices: true, customers: true } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Mandanten</h1>
|
||||||
|
<p className="text-gray-500 mt-1">{companies.length} Mandanten verwaltet</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/companies/new">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Mandant anlegen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{companies.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-16 text-center">
|
||||||
|
<Building2 className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||||
|
<h3 className="font-semibold text-gray-700 mb-2">Noch keine Mandanten</h3>
|
||||||
|
<p className="text-gray-500 mb-6 text-sm">Legen Sie Ihren ersten Mandanten an, um loszulegen.</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/companies/new">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Ersten Mandanten anlegen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{companies.map((company) => (
|
||||||
|
<Link key={company.id} href={`/companies/${company.id}`}>
|
||||||
|
<Card className="hover:shadow-md transition-all hover:border-indigo-200 cursor-pointer h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-indigo-50 shrink-0">
|
||||||
|
<Building2 className="h-5 w-5 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="text-base truncate">{company.name}</CardTitle>
|
||||||
|
{company.legalForm && (
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{company.legalForm}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-4 text-sm text-gray-600">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
{company._count.invoices} Rechnungen
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
{company._count.customers} Kunden
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{company.city && (
|
||||||
|
<p className="text-xs text-gray-400 mt-2">{company.zip} {company.city}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
|
|
||||||
|
export default async function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-gray-50">
|
||||||
|
<Sidebar userName={session.user.name} />
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { formatCurrency } from "@/lib/tax";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Building2, FileText, Euro, TrendingUp } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session!.user!.id!;
|
||||||
|
|
||||||
|
const [companies, invoiceStats] = await Promise.all([
|
||||||
|
prisma.company.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { _count: { select: { invoices: true, customers: true } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.invoice.aggregate({
|
||||||
|
where: { company: { userId } },
|
||||||
|
_count: true,
|
||||||
|
_sum: { grossTotal: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const paidTotal = await prisma.invoice.aggregate({
|
||||||
|
where: { company: { userId }, status: InvoiceStatus.PAID },
|
||||||
|
_sum: { grossTotal: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const openInvoices = await prisma.invoice.count({
|
||||||
|
where: { company: { userId }, status: { in: [InvoiceStatus.SENT, InvoiceStatus.DRAFT] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
|
<p className="text-gray-500 mt-1">Übersicht aller Mandanten und Rechnungen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2 rounded-lg bg-indigo-50">
|
||||||
|
<Building2 className="h-5 w-5 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{companies.length}</p>
|
||||||
|
<p className="text-sm text-gray-500">Mandanten</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2 rounded-lg bg-blue-50">
|
||||||
|
<FileText className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{invoiceStats._count}</p>
|
||||||
|
<p className="text-sm text-gray-500">Rechnungen gesamt</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2 rounded-lg bg-yellow-50">
|
||||||
|
<FileText className="h-5 w-5 text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{openInvoices}</p>
|
||||||
|
<p className="text-sm text-gray-500">Offen / Entwurf</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2 rounded-lg bg-green-50">
|
||||||
|
<Euro className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{formatCurrency(Number(paidTotal._sum.grossTotal ?? 0))}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">Bezahlt (brutto)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Companies */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Mandanten</h2>
|
||||||
|
<Link
|
||||||
|
href="/companies"
|
||||||
|
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium"
|
||||||
|
>
|
||||||
|
Alle anzeigen →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{companies.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<Building2 className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500 mb-4">Noch keine Mandanten angelegt.</p>
|
||||||
|
<Link
|
||||||
|
href="/companies/new"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
Mandant anlegen
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{companies.map((company) => (
|
||||||
|
<Link key={company.id} href={`/companies/${company.id}`}>
|
||||||
|
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{company.name}</CardTitle>
|
||||||
|
{company.legalForm && (
|
||||||
|
<p className="text-xs text-gray-500">{company.legalForm}</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-4 text-sm text-gray-600">
|
||||||
|
<span>{company._count.invoices} Rechnungen</span>
|
||||||
|
<span>{company._count.customers} Kunden</span>
|
||||||
|
</div>
|
||||||
|
{company.city && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{company.zip} {company.city}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Verify company belongs to user
|
||||||
|
const company = await prisma.company.findFirst({
|
||||||
|
where: { id, userId: session.user.id },
|
||||||
|
});
|
||||||
|
if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const customers = await prisma.customer.findMany({
|
||||||
|
where: { companyId: id },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(customers);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const company = await prisma.company.findFirst({
|
||||||
|
where: { id, userId: session.user.id },
|
||||||
|
});
|
||||||
|
if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const invoices = await prisma.invoice.findMany({
|
||||||
|
where: { companyId: id },
|
||||||
|
include: { customer: { select: { name: true } } },
|
||||||
|
orderBy: { issueDate: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(invoices);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getCompany(id: string, userId: string) {
|
||||||
|
return prisma.company.findFirst({ where: { id, userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const company = await getCompany(id, session.user.id);
|
||||||
|
if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json(company);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const company = await getCompany(id, session.user.id);
|
||||||
|
if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const parsed = companySchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
|
||||||
|
const updated = await prisma.company.update({
|
||||||
|
where: { id },
|
||||||
|
data: parsed.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const company = await getCompany(id, session.user.id);
|
||||||
|
if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
await prisma.company.delete({ where: { id } });
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
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().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"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const companies = await prisma.company.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
include: { _count: { select: { invoices: true, customers: true } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(companies);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const parsed = companySchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = await prisma.company.create({
|
||||||
|
data: { ...parsed.data, userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(company, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const customerSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
vatId: 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(),
|
||||||
|
email: z.string().email().optional().or(z.literal("")),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getCustomer(id: string, userId: string) {
|
||||||
|
return prisma.customer.findFirst({
|
||||||
|
where: { id, company: { userId } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
const customer = await getCustomer(id, session.user.id);
|
||||||
|
if (!customer) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
return NextResponse.json(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
const customer = await getCustomer(id, session.user.id);
|
||||||
|
if (!customer) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const parsed = customerSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
|
||||||
|
const updated = await prisma.customer.update({ where: { id }, data: parsed.data });
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
const customer = await getCustomer(id, session.user.id);
|
||||||
|
if (!customer) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
await prisma.customer.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const customerSchema = z.object({
|
||||||
|
companyId: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
vatId: 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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const parsed = customerSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
|
||||||
|
// Verify company belongs to user
|
||||||
|
const company = await prisma.company.findFirst({
|
||||||
|
where: { id: parsed.data.companyId, userId: session.user.id },
|
||||||
|
});
|
||||||
|
if (!company) return NextResponse.json({ error: "Company not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const customer = await prisma.customer.create({ data: parsed.data });
|
||||||
|
return NextResponse.json(customer, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const invoice = await prisma.invoice.findFirst({
|
||||||
|
where: { id, company: { userId: session.user.id } },
|
||||||
|
include: {
|
||||||
|
items: { orderBy: { position: "asc" } },
|
||||||
|
customer: true,
|
||||||
|
company: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invoice) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
// Dynamic import to avoid SSR bundling issues with @react-pdf/renderer
|
||||||
|
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);
|
||||||
|
|
||||||
|
return new NextResponse(new Uint8Array(buffer), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": `attachment; filename="rechnung-${invoice.number}.pdf"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
async function getInvoice(id: string, userId: string) {
|
||||||
|
return prisma.invoice.findFirst({
|
||||||
|
where: { id, company: { userId } },
|
||||||
|
include: {
|
||||||
|
items: { orderBy: { position: "asc" } },
|
||||||
|
customer: true,
|
||||||
|
company: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
const invoice = await getInvoice(id, session.user.id);
|
||||||
|
if (!invoice) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
return NextResponse.json(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusSchema = z.object({
|
||||||
|
status: z.nativeEnum(InvoiceStatus),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
const invoice = await getInvoice(id, session.user.id);
|
||||||
|
if (!invoice) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const parsed = statusSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
|
||||||
|
const updated = await prisma.invoice.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: parsed.data.status },
|
||||||
|
});
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
const invoice = await getInvoice(id, session.user.id);
|
||||||
|
if (!invoice) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
await prisma.invoice.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { generateInvoiceNumber } from "@/lib/invoice-number";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
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 invoiceSchema = z.object({
|
||||||
|
companyId: z.string().min(1),
|
||||||
|
customerId: z.string().min(1),
|
||||||
|
issueDate: z.string(),
|
||||||
|
deliveryDate: z.string().optional(),
|
||||||
|
dueDate: z.string(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
items: z.array(itemSchema).min(1),
|
||||||
|
netTotal: z.number(),
|
||||||
|
taxTotal: z.number(),
|
||||||
|
grossTotal: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const parsed = invoiceSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
|
||||||
|
const { items, companyId, ...invoiceData } = parsed.data;
|
||||||
|
|
||||||
|
// Verify company belongs to user
|
||||||
|
const company = await prisma.company.findFirst({
|
||||||
|
where: { id: companyId, userId: session.user.id },
|
||||||
|
});
|
||||||
|
if (!company) return NextResponse.json({ error: "Company not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const number = await generateInvoiceNumber(companyId);
|
||||||
|
|
||||||
|
const invoice = await prisma.invoice.create({
|
||||||
|
data: {
|
||||||
|
...invoiceData,
|
||||||
|
number,
|
||||||
|
companyId,
|
||||||
|
issueDate: new Date(invoiceData.issueDate),
|
||||||
|
deliveryDate: invoiceData.deliveryDate ? new Date(invoiceData.deliveryDate) : null,
|
||||||
|
dueDate: new Date(invoiceData.dueDate),
|
||||||
|
items: {
|
||||||
|
create: items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
taxRate: item.taxRate,
|
||||||
|
netAmount: item.netAmount,
|
||||||
|
taxAmount: item.taxAmount,
|
||||||
|
grossAmount: item.grossAmount,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { items: true, customer: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(invoice, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const companyId = searchParams.get("companyId");
|
||||||
|
const year = parseInt(searchParams.get("year") ?? String(new Date().getFullYear()));
|
||||||
|
|
||||||
|
if (!companyId) return NextResponse.json({ error: "companyId required" }, { status: 400 });
|
||||||
|
|
||||||
|
const company = await prisma.company.findFirst({
|
||||||
|
where: { id: companyId, userId: session.user.id },
|
||||||
|
});
|
||||||
|
if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
// Get all paid/sent invoices for the year
|
||||||
|
const invoices = await prisma.invoice.findMany({
|
||||||
|
where: {
|
||||||
|
companyId,
|
||||||
|
status: { in: [InvoiceStatus.PAID, InvoiceStatus.SENT] },
|
||||||
|
issueDate: {
|
||||||
|
gte: new Date(`${year}-01-01`),
|
||||||
|
lt: new Date(`${year + 1}-01-01`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
items: true,
|
||||||
|
customer: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { issueDate: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build monthly summary
|
||||||
|
const monthly: Record<number, {
|
||||||
|
month: number;
|
||||||
|
invoiceCount: number;
|
||||||
|
netTotal: number;
|
||||||
|
taxTotal: number;
|
||||||
|
grossTotal: number;
|
||||||
|
taxGroups: Record<number, { netAmount: number; taxAmount: number }>;
|
||||||
|
}> = {};
|
||||||
|
|
||||||
|
for (let m = 1; m <= 12; m++) {
|
||||||
|
monthly[m] = { month: m, invoiceCount: 0, netTotal: 0, taxTotal: 0, grossTotal: 0, taxGroups: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
const month = new Date(invoice.issueDate).getMonth() + 1;
|
||||||
|
const m = monthly[month];
|
||||||
|
m.invoiceCount++;
|
||||||
|
m.netTotal += Number(invoice.netTotal);
|
||||||
|
m.taxTotal += Number(invoice.taxTotal);
|
||||||
|
m.grossTotal += Number(invoice.grossTotal);
|
||||||
|
|
||||||
|
for (const item of invoice.items) {
|
||||||
|
const rate = Number(item.taxRate);
|
||||||
|
if (!m.taxGroups[rate]) m.taxGroups[rate] = { netAmount: 0, taxAmount: 0 };
|
||||||
|
m.taxGroups[rate].netAmount += Number(item.netAmount);
|
||||||
|
m.taxGroups[rate].taxAmount += Number(item.taxAmount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quarterly
|
||||||
|
const quarterly = [1, 2, 3, 4].map((q) => {
|
||||||
|
const months = [q * 3 - 2, q * 3 - 1, q * 3];
|
||||||
|
const data = months.map((m) => monthly[m]);
|
||||||
|
const taxGroups: Record<number, { netAmount: number; taxAmount: number }> = {};
|
||||||
|
|
||||||
|
for (const m of data) {
|
||||||
|
for (const [rate, group] of Object.entries(m.taxGroups)) {
|
||||||
|
const r = Number(rate);
|
||||||
|
if (!taxGroups[r]) taxGroups[r] = { netAmount: 0, taxAmount: 0 };
|
||||||
|
taxGroups[r].netAmount += group.netAmount;
|
||||||
|
taxGroups[r].taxAmount += group.taxAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
quarter: q,
|
||||||
|
invoiceCount: data.reduce((s, m) => s + m.invoiceCount, 0),
|
||||||
|
netTotal: data.reduce((s, m) => s + m.netTotal, 0),
|
||||||
|
taxTotal: data.reduce((s, m) => s + m.taxTotal, 0),
|
||||||
|
grossTotal: data.reduce((s, m) => s + m.grossTotal, 0),
|
||||||
|
taxGroups,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const yearTotal = {
|
||||||
|
invoiceCount: invoices.length,
|
||||||
|
netTotal: invoices.reduce((s, i) => s + Number(i.netTotal), 0),
|
||||||
|
taxTotal: invoices.reduce((s, i) => s + Number(i.taxTotal), 0),
|
||||||
|
grossTotal: invoices.reduce((s, i) => s + Number(i.grossTotal), 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({ year, monthly: Object.values(monthly), quarterly, yearTotal, invoices });
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #f9fafb;
|
||||||
|
--foreground: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Annas Rechnungsmanager",
|
||||||
|
description: "Buchhaltung & Rechnungsverwaltung für Mandanten",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="de">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, "Name ist erforderlich"),
|
||||||
|
legalForm: z.string().optional(),
|
||||||
|
taxId: z.string().optional(),
|
||||||
|
vatId: z.string().optional(),
|
||||||
|
address: z.string().min(1, "Adresse ist erforderlich"),
|
||||||
|
zip: z.string().min(1, "PLZ ist erforderlich"),
|
||||||
|
city: z.string().min(1, "Ort ist erforderlich"),
|
||||||
|
country: z.string().optional(),
|
||||||
|
email: z.string().email("Ungültige E-Mail").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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
interface CompanyFormProps {
|
||||||
|
defaultValues?: Partial<FormData>;
|
||||||
|
onSubmit: (data: FormData) => Promise<void>;
|
||||||
|
submitLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
{children}
|
||||||
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern" }: CompanyFormProps) {
|
||||||
|
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { country: "DE", invoicePrefix: "RE", ...defaultValues },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<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}>
|
||||||
|
<Input {...register("name")} placeholder="Muster GmbH" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Rechtsform" error={errors.legalForm?.message}>
|
||||||
|
<Input {...register("legalForm")} placeholder="GmbH, AG, UG..." />
|
||||||
|
</Field>
|
||||||
|
<Field label="Steuernummer" error={errors.taxId?.message}>
|
||||||
|
<Input {...register("taxId")} placeholder="123/456/78901" />
|
||||||
|
</Field>
|
||||||
|
<Field label="USt-IdNr." error={errors.vatId?.message}>
|
||||||
|
<Input {...register("vatId")} placeholder="DE123456789" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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}>
|
||||||
|
<Input {...register("address")} placeholder="Musterstraße 1" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="PLZ *" error={errors.zip?.message}>
|
||||||
|
<Input {...register("zip")} placeholder="10115" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Ort *" error={errors.city?.message}>
|
||||||
|
<Input {...register("city")} placeholder="Berlin" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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}>
|
||||||
|
<Input {...register("email")} type="email" placeholder="info@firma.de" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Telefon" error={errors.phone?.message}>
|
||||||
|
<Input {...register("phone")} placeholder="+49 30 12345678" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Website" error={errors.website?.message}>
|
||||||
|
<Input {...register("website")} placeholder="https://firma.de" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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}>
|
||||||
|
<Input {...register("bankIban")} placeholder="DE89 3704 0044 0532 0130 00" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="BIC" error={errors.bankBic?.message}>
|
||||||
|
<Input {...register("bankBic")} placeholder="COBADEFFXXX" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Kreditinstitut" error={errors.bankName?.message}>
|
||||||
|
<Input {...register("bankName")} placeholder="Commerzbank" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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}>
|
||||||
|
<Input {...register("invoicePrefix")} placeholder="RE" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Format: {"{Präfix}"}-{"{Jahr}"}-{"{Nummer}"} z.B. RE-2024-001</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Speichern..." : submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { useForm, useFieldArray } from "react-hook-form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { calcItemAmounts, calcInvoiceTotals, formatCurrency, TAX_RATES } from "@/lib/tax";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface Customer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemFormData {
|
||||||
|
position: number;
|
||||||
|
description: string;
|
||||||
|
quantity: string;
|
||||||
|
unit: string;
|
||||||
|
unitPrice: string;
|
||||||
|
taxRate: string;
|
||||||
|
netAmount: number;
|
||||||
|
taxAmount: number;
|
||||||
|
grossAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoiceFormValues {
|
||||||
|
customerId: string;
|
||||||
|
issueDate: string;
|
||||||
|
deliveryDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
notes: string;
|
||||||
|
items: ItemFormData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoiceFormProps {
|
||||||
|
customers: Customer[];
|
||||||
|
companyId: string;
|
||||||
|
onSubmit: (data: Record<string, unknown>) => Promise<void>;
|
||||||
|
defaultValues?: Partial<InvoiceFormValues>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultItem = (): ItemFormData => ({
|
||||||
|
position: 1,
|
||||||
|
description: "",
|
||||||
|
quantity: "1",
|
||||||
|
unit: "Stück",
|
||||||
|
unitPrice: "0.00",
|
||||||
|
taxRate: "19",
|
||||||
|
netAmount: 0,
|
||||||
|
taxAmount: 0,
|
||||||
|
grossAmount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps) {
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const dueDefault = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const { register, handleSubmit, watch, setValue, control, formState: { errors, isSubmitting } } = useForm<InvoiceFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
customerId: "",
|
||||||
|
issueDate: today,
|
||||||
|
deliveryDate: today,
|
||||||
|
dueDate: dueDefault,
|
||||||
|
notes: "",
|
||||||
|
items: [defaultItem()],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({ control, name: "items" });
|
||||||
|
const watchedItems = watch("items");
|
||||||
|
|
||||||
|
const recalcItem = useCallback((index: number) => {
|
||||||
|
const item = watchedItems[index];
|
||||||
|
if (!item) return;
|
||||||
|
const qty = parseFloat(item.quantity) || 0;
|
||||||
|
const price = parseFloat(item.unitPrice) || 0;
|
||||||
|
const rate = parseFloat(item.taxRate) || 0;
|
||||||
|
const { netAmount, taxAmount, grossAmount } = calcItemAmounts(qty, price, rate);
|
||||||
|
setValue(`items.${index}.netAmount`, netAmount);
|
||||||
|
setValue(`items.${index}.taxAmount`, taxAmount);
|
||||||
|
setValue(`items.${index}.grossAmount`, grossAmount);
|
||||||
|
}, [watchedItems, setValue]);
|
||||||
|
|
||||||
|
const totals = calcInvoiceTotals(
|
||||||
|
watchedItems.map((item) => ({
|
||||||
|
netAmount: item.netAmount ?? 0,
|
||||||
|
taxAmount: item.taxAmount ?? 0,
|
||||||
|
grossAmount: item.grossAmount ?? 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleFormSubmit(data: InvoiceFormValues) {
|
||||||
|
const items = data.items.map((item, i) => {
|
||||||
|
const qty = parseFloat(item.quantity) || 0;
|
||||||
|
const price = parseFloat(item.unitPrice) || 0;
|
||||||
|
const rate = parseFloat(item.taxRate) || 0;
|
||||||
|
const { netAmount, taxAmount, grossAmount } = calcItemAmounts(qty, price, rate);
|
||||||
|
return {
|
||||||
|
position: i + 1,
|
||||||
|
description: item.description,
|
||||||
|
quantity: qty,
|
||||||
|
unit: item.unit || undefined,
|
||||||
|
unitPrice: price,
|
||||||
|
taxRate: rate,
|
||||||
|
netAmount,
|
||||||
|
taxAmount,
|
||||||
|
grossAmount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totals = calcInvoiceTotals(items);
|
||||||
|
|
||||||
|
await onSubmit({
|
||||||
|
companyId,
|
||||||
|
customerId: data.customerId,
|
||||||
|
issueDate: data.issueDate,
|
||||||
|
deliveryDate: data.deliveryDate || undefined,
|
||||||
|
dueDate: data.dueDate,
|
||||||
|
notes: data.notes || undefined,
|
||||||
|
items,
|
||||||
|
...totals,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||||
|
{/* Header info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Kunde *</Label>
|
||||||
|
<Select onValueChange={(v) => setValue("customerId", v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Kunde auswählen..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.customerId && <p className="text-xs text-red-600">Pflichtfeld</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Rechnungsdatum *</Label>
|
||||||
|
<Input type="date" {...register("issueDate", { required: true })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Leistungsdatum</Label>
|
||||||
|
<Input type="date" {...register("deliveryDate")} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Fällig am *</Label>
|
||||||
|
<Input type="date" {...register("dueDate", { required: true })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Rechnungspositionen</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => append(defaultItem())}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Position hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-gray-200 rounded-xl overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="grid grid-cols-12 gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600">
|
||||||
|
<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">Einzelpreis</div>
|
||||||
|
<div className="col-span-1">MwSt.</div>
|
||||||
|
<div className="col-span-2 text-right">Gesamt (brutto)</div>
|
||||||
|
<div className="col-span-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={field.id} className="grid grid-cols-12 gap-2 px-3 py-2.5 border-b border-gray-100 last:border-0 items-center">
|
||||||
|
<div className="col-span-4">
|
||||||
|
<Input
|
||||||
|
{...register(`items.${index}.description`, { required: true })}
|
||||||
|
placeholder="Leistungsbeschreibung"
|
||||||
|
className="text-sm"
|
||||||
|
onChange={(e) => {
|
||||||
|
register(`items.${index}.description`).onChange(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Input
|
||||||
|
{...register(`items.${index}.quantity`)}
|
||||||
|
className="text-sm text-right"
|
||||||
|
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`)}
|
||||||
|
className="text-sm text-right"
|
||||||
|
onBlur={() => recalcItem(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Select
|
||||||
|
defaultValue="19"
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setValue(`items.${index}.taxRate`, v);
|
||||||
|
recalcItem(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TAX_RATES.map((r) => (
|
||||||
|
<SelectItem key={r.value} value={String(r.value)}>
|
||||||
|
{r.value}%
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-right text-sm font-medium text-gray-900">
|
||||||
|
{formatCurrency(watchedItems[index]?.grossAmount ?? 0)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 flex justify-end">
|
||||||
|
{fields.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-red-400 hover:text-red-600 hover:bg-red-50"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<div className="w-64 space-y-1.5">
|
||||||
|
<div className="flex justify-between text-sm text-gray-600">
|
||||||
|
<span>Netto</span>
|
||||||
|
<span>{formatCurrency(totals.netTotal)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm text-gray-600">
|
||||||
|
<span>MwSt.</span>
|
||||||
|
<span>{formatCurrency(totals.taxTotal)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-base font-semibold text-gray-900 border-t border-gray-200 pt-1.5">
|
||||||
|
<span>Gesamt (brutto)</span>
|
||||||
|
<span>{formatCurrency(totals.grossTotal)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Anmerkungen</Label>
|
||||||
|
<Textarea {...register("notes")} placeholder="Zahlungsbedingungen, Hinweise..." rows={3} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<Button type="submit" disabled={isSubmitting} size="lg">
|
||||||
|
{isSubmitting ? "Erstelle Rechnung..." : "Rechnung erstellen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Document,
|
||||||
|
Page,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
fontFamily: "Helvetica",
|
||||||
|
fontSize: 9,
|
||||||
|
color: "#111827",
|
||||||
|
paddingTop: 50,
|
||||||
|
paddingBottom: 60,
|
||||||
|
paddingLeft: 55,
|
||||||
|
paddingRight: 55,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 30,
|
||||||
|
},
|
||||||
|
companyName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
color: "#1e1b4b",
|
||||||
|
marginBottom: 3,
|
||||||
|
},
|
||||||
|
companyInfo: {
|
||||||
|
fontSize: 8,
|
||||||
|
color: "#6b7280",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
invoiceTitle: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
color: "#1e1b4b",
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
metaGrid: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 0,
|
||||||
|
marginBottom: 20,
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
metaItem: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
metaLabel: {
|
||||||
|
fontSize: 7,
|
||||||
|
color: "#9ca3af",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
metaValue: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
color: "#111827",
|
||||||
|
},
|
||||||
|
addressSection: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 25,
|
||||||
|
},
|
||||||
|
addressBlock: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
addressLabel: {
|
||||||
|
fontSize: 7,
|
||||||
|
color: "#9ca3af",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
addressName: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
color: "#111827",
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
addressLine: {
|
||||||
|
fontSize: 8.5,
|
||||||
|
color: "#374151",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
tableHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: "#1e1b4b",
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
borderRadius: 3,
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
tableHeaderText: {
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: 8,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
},
|
||||||
|
tableRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingVertical: 5,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
borderBottomColor: "#e5e7eb",
|
||||||
|
borderBottomWidth: 0.5,
|
||||||
|
},
|
||||||
|
tableRowAlt: {
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
},
|
||||||
|
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_tax: { width: "8%", textAlign: "center" },
|
||||||
|
col_total: { width: "15%", textAlign: "right" },
|
||||||
|
totalsSection: {
|
||||||
|
marginTop: 15,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
totalsTable: {
|
||||||
|
width: 220,
|
||||||
|
},
|
||||||
|
totalsRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
totalsLabel: {
|
||||||
|
fontSize: 9,
|
||||||
|
color: "#6b7280",
|
||||||
|
},
|
||||||
|
totalsValue: {
|
||||||
|
fontSize: 9,
|
||||||
|
color: "#374151",
|
||||||
|
},
|
||||||
|
totalsFinalRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderTopColor: "#1e1b4b",
|
||||||
|
borderTopWidth: 1.5,
|
||||||
|
marginTop: 3,
|
||||||
|
},
|
||||||
|
totalsFinalLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
color: "#1e1b4b",
|
||||||
|
},
|
||||||
|
totalsFinalValue: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
color: "#1e1b4b",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 10,
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
notesLabel: {
|
||||||
|
fontSize: 7,
|
||||||
|
color: "#9ca3af",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
marginBottom: 3,
|
||||||
|
},
|
||||||
|
notesText: {
|
||||||
|
fontSize: 8.5,
|
||||||
|
color: "#374151",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 30,
|
||||||
|
left: 55,
|
||||||
|
right: 55,
|
||||||
|
borderTopColor: "#e5e7eb",
|
||||||
|
borderTopWidth: 0.5,
|
||||||
|
paddingTop: 8,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
fontSize: 7,
|
||||||
|
color: "#9ca3af",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatMoney(amount: number) {
|
||||||
|
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date | string) {
|
||||||
|
return new Intl.DateTimeFormat("de-DE").format(new Date(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoicePDFProps {
|
||||||
|
invoice: {
|
||||||
|
number: string;
|
||||||
|
issueDate: Date | string;
|
||||||
|
deliveryDate?: Date | string | null;
|
||||||
|
dueDate: Date | string;
|
||||||
|
notes?: string | null;
|
||||||
|
netTotal: number | string | { toString(): string };
|
||||||
|
taxTotal: number | string | { toString(): string };
|
||||||
|
grossTotal: number | string | { toString(): string };
|
||||||
|
company: {
|
||||||
|
name: string;
|
||||||
|
legalForm?: string | null;
|
||||||
|
taxId?: string | null;
|
||||||
|
vatId?: string | null;
|
||||||
|
address: string;
|
||||||
|
zip: string;
|
||||||
|
city: string;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
bankIban?: string | null;
|
||||||
|
bankBic?: string | null;
|
||||||
|
bankName?: string | null;
|
||||||
|
};
|
||||||
|
customer: {
|
||||||
|
name: string;
|
||||||
|
vatId?: string | null;
|
||||||
|
address: string;
|
||||||
|
zip: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
items: Array<{
|
||||||
|
position: number;
|
||||||
|
description: string;
|
||||||
|
quantity: number | string | { toString(): string };
|
||||||
|
unit?: string | null;
|
||||||
|
unitPrice: number | string | { toString(): string };
|
||||||
|
taxRate: number | string | { toString(): string };
|
||||||
|
netAmount: number | string | { toString(): string };
|
||||||
|
taxAmount: number | string | { toString(): string };
|
||||||
|
grossAmount: number | string | { toString(): string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
|
||||||
|
const n = (v: unknown) => Number(v);
|
||||||
|
|
||||||
|
// Group items by tax rate
|
||||||
|
const taxGroups = invoice.items.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
const rate = n(item.taxRate);
|
||||||
|
if (!acc[rate]) acc[rate] = { net: 0, tax: 0 };
|
||||||
|
acc[rate].net += n(item.netAmount);
|
||||||
|
acc[rate].tax += n(item.taxAmount);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<number, { net: number; tax: number }>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4" style={styles.page}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.companyName}>{invoice.company.name}</Text>
|
||||||
|
{invoice.company.legalForm && (
|
||||||
|
<Text style={styles.companyInfo}>{invoice.company.legalForm}</Text>
|
||||||
|
)}
|
||||||
|
<Text style={styles.companyInfo}>{invoice.company.address}</Text>
|
||||||
|
<Text style={styles.companyInfo}>{invoice.company.zip} {invoice.company.city}</Text>
|
||||||
|
{invoice.company.email && (
|
||||||
|
<Text style={styles.companyInfo}>{invoice.company.email}</Text>
|
||||||
|
)}
|
||||||
|
{invoice.company.phone && (
|
||||||
|
<Text style={styles.companyInfo}>{invoice.company.phone}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={{ alignItems: "flex-end" }}>
|
||||||
|
{invoice.company.taxId && (
|
||||||
|
<Text style={styles.companyInfo}>St.-Nr.: {invoice.company.taxId}</Text>
|
||||||
|
)}
|
||||||
|
{invoice.company.vatId && (
|
||||||
|
<Text style={styles.companyInfo}>USt-IdNr.: {invoice.company.vatId}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Invoice Title */}
|
||||||
|
<Text style={styles.invoiceTitle}>Rechnung</Text>
|
||||||
|
|
||||||
|
{/* Address Section */}
|
||||||
|
<View style={styles.addressSection}>
|
||||||
|
<View style={styles.addressBlock}>
|
||||||
|
<Text style={styles.addressLabel}>Rechnungsempfänger</Text>
|
||||||
|
<Text style={styles.addressName}>{invoice.customer.name}</Text>
|
||||||
|
<Text style={styles.addressLine}>{invoice.customer.address}</Text>
|
||||||
|
<Text style={styles.addressLine}>{invoice.customer.zip} {invoice.customer.city}</Text>
|
||||||
|
{invoice.customer.country !== "DE" && (
|
||||||
|
<Text style={styles.addressLine}>{invoice.customer.country}</Text>
|
||||||
|
)}
|
||||||
|
{invoice.customer.vatId && (
|
||||||
|
<Text style={{ ...styles.addressLine, marginTop: 3 }}>
|
||||||
|
USt-IdNr.: {invoice.customer.vatId}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<View style={styles.metaGrid}>
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<Text style={styles.metaLabel}>Rechnungsnummer</Text>
|
||||||
|
<Text style={styles.metaValue}>{invoice.number}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<Text style={styles.metaLabel}>Rechnungsdatum</Text>
|
||||||
|
<Text style={styles.metaValue}>{formatDate(invoice.issueDate)}</Text>
|
||||||
|
</View>
|
||||||
|
{invoice.deliveryDate && (
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<Text style={styles.metaLabel}>Leistungsdatum</Text>
|
||||||
|
<Text style={styles.metaValue}>{formatDate(invoice.deliveryDate)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<Text style={styles.metaLabel}>Fällig am</Text>
|
||||||
|
<Text style={styles.metaValue}>{formatDate(invoice.dueDate)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Items Table */}
|
||||||
|
<View style={styles.tableHeader}>
|
||||||
|
<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 }}>EP (netto)</Text>
|
||||||
|
<Text style={{ ...styles.tableHeaderText, ...styles.col_tax }}>MwSt.</Text>
|
||||||
|
<Text style={{ ...styles.tableHeaderText, ...styles.col_total }}>Gesamt (brutto)</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{invoice.items.map((item, idx) => (
|
||||||
|
<View key={idx} style={[styles.tableRow, idx % 2 === 1 ? styles.tableRowAlt : {}]}>
|
||||||
|
<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>
|
||||||
|
<Text style={{ ...styles.col_tax, fontSize: 9 }}>{n(item.taxRate)}%</Text>
|
||||||
|
<Text style={{ ...styles.col_total, fontSize: 9, fontFamily: "Helvetica-Bold" }}>
|
||||||
|
{formatMoney(n(item.grossAmount))}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<View style={styles.totalsSection}>
|
||||||
|
<View style={styles.totalsTable}>
|
||||||
|
<View style={styles.totalsRow}>
|
||||||
|
<Text style={styles.totalsLabel}>Nettobetrag</Text>
|
||||||
|
<Text style={styles.totalsValue}>{formatMoney(n(invoice.netTotal))}</Text>
|
||||||
|
</View>
|
||||||
|
{Object.entries(taxGroups).map(([rate, { net, tax }]) => (
|
||||||
|
<View key={rate} style={styles.totalsRow}>
|
||||||
|
<Text style={styles.totalsLabel}>MwSt. {rate}% auf {formatMoney(net)}</Text>
|
||||||
|
<Text style={styles.totalsValue}>{formatMoney(tax)}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View style={styles.totalsFinalRow}>
|
||||||
|
<Text style={styles.totalsFinalLabel}>Gesamtbetrag (inkl. MwSt.)</Text>
|
||||||
|
<Text style={styles.totalsFinalValue}>{formatMoney(n(invoice.grossTotal))}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{invoice.notes && (
|
||||||
|
<View style={styles.notes}>
|
||||||
|
<Text style={styles.notesLabel}>Hinweise</Text>
|
||||||
|
<Text style={styles.notesText}>{invoice.notes}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View style={styles.footer} fixed>
|
||||||
|
<Text style={styles.footerText}>
|
||||||
|
{invoice.company.name}
|
||||||
|
{invoice.company.taxId ? ` · St.-Nr.: ${invoice.company.taxId}` : ""}
|
||||||
|
{invoice.company.vatId ? ` · USt-IdNr.: ${invoice.company.vatId}` : ""}
|
||||||
|
</Text>
|
||||||
|
{invoice.company.bankIban && (
|
||||||
|
<Text style={styles.footerText}>
|
||||||
|
{invoice.company.bankName ? `${invoice.company.bankName} · ` : ""}
|
||||||
|
IBAN: {invoice.company.bankIban}
|
||||||
|
{invoice.company.bankBic ? ` · BIC: ${invoice.company.bankBic}` : ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
const statusConfig: Record<InvoiceStatus, { label: string; variant: "secondary" | "default" | "success" | "destructive" | "warning" }> = {
|
||||||
|
DRAFT: { label: "Entwurf", variant: "secondary" },
|
||||||
|
SENT: { label: "Versendet", variant: "warning" },
|
||||||
|
PAID: { label: "Bezahlt", variant: "success" },
|
||||||
|
CANCELLED: { label: "Storniert", variant: "destructive" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvoiceStatusBadge({ status }: { status: InvoiceStatus }) {
|
||||||
|
const config = statusConfig[status];
|
||||||
|
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
import {
|
||||||
|
Calculator,
|
||||||
|
Building2,
|
||||||
|
FileText,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ label: "Dashboard", href: "/", icon: <LayoutDashboard className="h-4 w-4" /> },
|
||||||
|
{ label: "Mandanten", href: "/companies", icon: <Building2 className="h-4 w-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar({ userName }: { userName?: string | null }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-60 shrink-0 flex flex-col bg-white border-r border-gray-200 min-h-screen">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-5 border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600">
|
||||||
|
<Calculator className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-gray-900 text-sm leading-tight">
|
||||||
|
Rechnungs-<br />manager
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 px-3 py-4 space-y-0.5">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const active = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-indigo-50 text-indigo-700"
|
||||||
|
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
{active && <ChevronRight className="ml-auto h-3 w-3 text-indigo-400" />}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User */}
|
||||||
|
<div className="px-3 py-4 border-t border-gray-200">
|
||||||
|
{userName && (
|
||||||
|
<p className="text-xs text-gray-500 px-3 mb-2 truncate">{userName}</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start text-gray-600 hover:text-red-600 hover:bg-red-50"
|
||||||
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent bg-indigo-100 text-indigo-800",
|
||||||
|
secondary: "border-transparent bg-gray-100 text-gray-800",
|
||||||
|
destructive: "border-transparent bg-red-100 text-red-800",
|
||||||
|
success: "border-transparent bg-green-100 text-green-800",
|
||||||
|
warning: "border-transparent bg-yellow-100 text-yellow-800",
|
||||||
|
outline: "text-gray-700 border-gray-300",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-indigo-600 text-white shadow hover:bg-indigo-700",
|
||||||
|
destructive: "bg-red-500 text-white shadow-sm hover:bg-red-600",
|
||||||
|
outline: "border border-gray-300 bg-white shadow-sm hover:bg-gray-50 text-gray-700",
|
||||||
|
secondary: "bg-gray-100 text-gray-900 shadow-sm hover:bg-gray-200",
|
||||||
|
ghost: "hover:bg-gray-100 text-gray-700",
|
||||||
|
link: "text-indigo-600 underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-lg px-6",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("rounded-xl border border-gray-200 bg-white shadow-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3 ref={ref} className={cn("font-semibold leading-none tracking-tight text-gray-900", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn("text-sm text-gray-500", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn("fixed inset-0 z-50 bg-black/50 backdrop-blur-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border border-gray-200 bg-white p-6 shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogClose className="absolute right-4 top-4 rounded-md opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Schließen</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold leading-none tracking-tight text-gray-900", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-gray-500", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose,
|
||||||
|
DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-lg border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-medium text-gray-700 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 min-w-[8rem] overflow-hidden rounded-lg border border-gray-200 bg-white text-gray-900 shadow-lg animate-in fade-in-0 zoom-in-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-indigo-50 focus:text-indigo-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-8 py-1.5 text-xs font-semibold text-gray-500", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem, SelectLabel };
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import prisma from "./prisma";
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
name: "credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "E-Mail", type: "email" },
|
||||||
|
password: { label: "Passwort", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials?.password) return null;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: credentials.email as string },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const passwordValid = await bcrypt.compare(
|
||||||
|
credentials.password as string,
|
||||||
|
user.passwordHash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!passwordValid) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (token && session.user) {
|
||||||
|
session.user.id = token.id as string;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import prisma from "./prisma";
|
||||||
|
|
||||||
|
export async function generateInvoiceNumber(companyId: string): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
// Atomically increment the sequence number
|
||||||
|
const company = await prisma.company.update({
|
||||||
|
where: { id: companyId },
|
||||||
|
data: { invoiceSequence: { increment: 1 } },
|
||||||
|
select: { invoicePrefix: true, invoiceSequence: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const seq = String(company.invoiceSequence).padStart(3, "0");
|
||||||
|
return `${company.invoicePrefix}-${year}-${seq}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export const TAX_RATES = [
|
||||||
|
{ label: "19% MwSt. (Regelsteuersatz)", value: 19 },
|
||||||
|
{ label: "7% MwSt. (ermäßigt)", value: 7 },
|
||||||
|
{ label: "0% (steuerfrei / §13b UStG)", value: 0 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function calcItemAmounts(
|
||||||
|
quantity: number,
|
||||||
|
unitPrice: number,
|
||||||
|
taxRate: number
|
||||||
|
) {
|
||||||
|
const netAmount = Math.round(quantity * unitPrice * 100) / 100;
|
||||||
|
const taxAmount = Math.round(netAmount * (taxRate / 100) * 100) / 100;
|
||||||
|
const grossAmount = Math.round((netAmount + taxAmount) * 100) / 100;
|
||||||
|
return { netAmount, taxAmount, grossAmount };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcInvoiceTotals(
|
||||||
|
items: Array<{ netAmount: number; taxAmount: number; grossAmount: number }>
|
||||||
|
) {
|
||||||
|
const netTotal = items.reduce((sum, i) => sum + i.netAmount, 0);
|
||||||
|
const taxTotal = items.reduce((sum, i) => sum + i.taxAmount, 0);
|
||||||
|
const grossTotal = items.reduce((sum, i) => sum + i.grossAmount, 0);
|
||||||
|
return {
|
||||||
|
netTotal: Math.round(netTotal * 100) / 100,
|
||||||
|
taxTotal: Math.round(taxTotal * 100) / 100,
|
||||||
|
grossTotal: Math.round(grossTotal * 100) / 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number | string): string {
|
||||||
|
return new Intl.NumberFormat("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(Number(amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: Date | string): string {
|
||||||
|
return new Intl.DateTimeFormat("de-DE").format(new Date(date));
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export default auth((req) => {
|
||||||
|
const isLoggedIn = !!req.auth;
|
||||||
|
const isAuthPage = req.nextUrl.pathname.startsWith("/login");
|
||||||
|
const isApiAuth = req.nextUrl.pathname.startsWith("/api/auth");
|
||||||
|
|
||||||
|
if (isApiAuth) return NextResponse.next();
|
||||||
|
|
||||||
|
if (!isLoggedIn && !isAuthPage) {
|
||||||
|
return NextResponse.redirect(new URL("/login", req.nextUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoggedIn && isAuthPage) {
|
||||||
|
return NextResponse.redirect(new URL("/", req.nextUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|public).*)"],
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export type { Company, Customer, Invoice, InvoiceItem, User, InvoiceStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
export interface InvoiceItemInput {
|
||||||
|
id?: string;
|
||||||
|
position: number;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unit?: string;
|
||||||
|
unitPrice: number;
|
||||||
|
taxRate: number;
|
||||||
|
netAmount: number;
|
||||||
|
taxAmount: number;
|
||||||
|
grossAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceFormData {
|
||||||
|
customerId: string;
|
||||||
|
issueDate: string;
|
||||||
|
deliveryDate?: string;
|
||||||
|
dueDate: string;
|
||||||
|
notes?: string;
|
||||||
|
items: InvoiceItemInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyFormData {
|
||||||
|
name: string;
|
||||||
|
legalForm?: string;
|
||||||
|
taxId?: string;
|
||||||
|
vatId?: string;
|
||||||
|
address: string;
|
||||||
|
zip: string;
|
||||||
|
city: string;
|
||||||
|
country?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
website?: string;
|
||||||
|
bankIban?: string;
|
||||||
|
bankBic?: string;
|
||||||
|
bankName?: string;
|
||||||
|
invoicePrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerFormData {
|
||||||
|
name: string;
|
||||||
|
vatId?: string;
|
||||||
|
taxId?: string;
|
||||||
|
address: string;
|
||||||
|
zip: string;
|
||||||
|
city: string;
|
||||||
|
country?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxGroupSummary {
|
||||||
|
taxRate: number;
|
||||||
|
netAmount: number;
|
||||||
|
taxAmount: number;
|
||||||
|
grossAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeriodReport {
|
||||||
|
period: string;
|
||||||
|
invoiceCount: number;
|
||||||
|
netTotal: number;
|
||||||
|
taxTotal: number;
|
||||||
|
grossTotal: number;
|
||||||
|
taxGroups: TaxGroupSummary[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user