diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..16b990f
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+node_modules
+build
+.env
+.git
+*.md
+src/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..deb164f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,31 @@
+# ---- Build Stage ----
+FROM node:22-alpine AS builder
+
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm ci
+
+COPY prisma ./prisma
+RUN npx prisma generate
+
+COPY . .
+RUN npm run build
+
+# ---- Production Stage ----
+FROM node:22-alpine AS runner
+
+WORKDIR /app
+
+ENV NODE_ENV=production
+
+COPY package*.json ./
+RUN npm ci --omit=dev
+
+COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
+COPY --from=builder /app/build ./build
+COPY prisma ./prisma
+
+EXPOSE 3000
+
+CMD ["npm", "run", "start"]
diff --git a/README.md b/README.md
index 65991da..de9b88b 100644
--- a/README.md
+++ b/README.md
@@ -12,57 +12,100 @@ Buchhaltungs- und Rechnungsverwaltungssystem für Mandanten.
## Tech Stack
- **React Router v7** (Framework Mode, SSR) + TypeScript
-- **MariaDB / MySQL** via Prisma ORM
-- **Cookie-Session-Auth** (bcryptjs, kein NextAuth)
+- **MariaDB** via Prisma ORM
+- **Cookie-Session-Auth** (bcryptjs)
- **Tailwind CSS v4** + shadcn/ui
- **@react-pdf/renderer** für PDF-Generierung
- **Docker** für die Datenbank
-## Setup
+## Lokale Entwicklung
-### 1. Voraussetzungen
+### Voraussetzungen
-- Node.js 20+
-- Docker (für MariaDB)
+- Node.js 22+
+- Docker + Docker Compose
-### 2. Installation
+### Installation
```bash
npm install
```
-### 3. Umgebungsvariablen konfigurieren
+### Umgebungsvariablen
-```bash
-cp .env.example .env
-# DATABASE_URL und AUTH_SECRET in .env anpassen
+`.env` im Projektstamm anlegen:
+
+```env
+DATABASE_URL="mysql://annas_user:annas_password@localhost:3306/annas_rechnungen"
+AUTH_SECRET="dein-zufaelliger-secret-string"
```
-### 4. Datenbank einrichten
+### Datenbank einrichten
```bash
-npx prisma migrate dev --name init
-npx prisma db seed
+npm run db:migrate # Migrationen ausführen
+npm run db:seed # Demo-Daten einspielen
```
**Demo-Zugangsdaten:** `anna@example.de` / `demo123`
-### 5. Entwicklungsserver starten
+### Entwicklungsserver starten
```bash
npm run dev
```
-Startet Docker (PostgreSQL) und den Vite-Dev-Server.
+Startet automatisch MariaDB via Docker und den Vite-Dev-Server auf `http://localhost:5173`.
-Öffne [http://localhost:5173](http://localhost:5173)
+## Scripts
-## Datenbank-Kommandos
+| Befehl | Beschreibung |
+|---|---|
+| `npm run dev` | DB starten + Dev-Server |
+| `npm run build` | Produktions-Build |
+| `npm run start` | Produktions-Server starten |
+| `npm run typecheck` | TypeScript prüfen |
+| `npm run db:migrate` | Prisma Migrationen ausführen |
+| `npm run db:seed` | Demo-Daten einspielen |
+| `npm run db:studio` | Prisma Studio öffnen |
+
+## Deployment
+
+### Docker Compose
+
+Startet App + MariaDB als komplettes Setup:
```bash
-npm run db:migrate # Migrationen ausführen
-npm run db:seed # Demo-Daten einspielen
-npm run db:studio # Prisma Studio öffnen
+docker compose up -d
+```
+
+Die App läuft auf `http://localhost:3000`. Migrationen werden automatisch beim Start ausgeführt.
+
+> `AUTH_SECRET` muss als Umgebungsvariable gesetzt sein.
+
+### Kubernetes
+
+```bash
+# Secret-Werte in k8s.yml anpassen (auth-secret, ggf. Passwörter), dann:
+kubectl apply -f k8s.yml
+```
+
+Das Manifest (`k8s.yml`) enthält: Namespace, Secret, MariaDB (PVC + Deployment + Service), App (Deployment mit Init-Container für Migrationen + Service + Ingress).
+
+## Projektstruktur
+
+```
+app/
+ components/ UI-Komponenten (ui/, layout/, company/, invoice/)
+ lib/ Hilfsfunktionen (prisma, tax, utils, invoice-number)
+ routes/ Route-Dateien (React Router v7, file-based)
+ session.server.ts Auth (Login, Logout, Session)
+ types/ Gemeinsame TypeScript-Typen
+db/
+ docker-compose.yml MariaDB + phpMyAdmin für lokale Entwicklung
+prisma/
+ schema.prisma Datenbankschema
+ migrations/ Migrationsverlauf
```
## Rechnungs-Compliance (§14 UStG)
diff --git a/app/components/layout/topbar.tsx b/app/components/layout/topbar.tsx
index 6f3ea7c..5d687b8 100644
--- a/app/components/layout/topbar.tsx
+++ b/app/components/layout/topbar.tsx
@@ -1,5 +1,5 @@
-import { useMatches, Link } from "react-router";
-import { ChevronRight } from "lucide-react";
+import { useMatches, useLocation, Link } from "react-router";
+import { ChevronRight, LayoutDashboard } from "lucide-react";
interface Breadcrumb {
label: string;
@@ -26,6 +26,8 @@ function getInitials(name?: string | null): string {
export function Topbar({ userName }: { userName?: string | null }) {
const matches = useMatches();
+ const location = useLocation();
+ const isOnDashboard = location.pathname === "/";
const activeMatch = [...matches].reverse().find((m) => isBreadcrumbHandle(m.handle));
const breadcrumbs: Breadcrumb[] =
@@ -83,6 +85,28 @@ export function Topbar({ userName }: { userName?: string | null }) {
)}
+ {/* Dashboard Button */}
+ {!isOnDashboard && (
+
+
+ Dashboard
+
+ )}
+
{/* User */}
{userName && (
diff --git a/app/routes/api.companies.ts b/app/routes/api.companies.ts
index 5a1439b..f262af3 100644
--- a/app/routes/api.companies.ts
+++ b/app/routes/api.companies.ts
@@ -6,7 +6,6 @@ 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),
diff --git a/app/routes/api.customers.$id.ts b/app/routes/api.customers.$id.ts
index 2d4af45..578b067 100644
--- a/app/routes/api.customers.$id.ts
+++ b/app/routes/api.customers.$id.ts
@@ -4,7 +4,6 @@ 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),
diff --git a/app/routes/api.customers.ts b/app/routes/api.customers.ts
index 63c1256..2f54e0d 100644
--- a/app/routes/api.customers.ts
+++ b/app/routes/api.customers.ts
@@ -5,7 +5,6 @@ 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),
diff --git a/app/routes/companies.$id.customers.tsx b/app/routes/companies.$id.customers.tsx
index 81d95bb..8fc53f9 100644
--- a/app/routes/companies.$id.customers.tsx
+++ b/app/routes/companies.$id.customers.tsx
@@ -22,7 +22,7 @@ import { z } from "zod";
const schema = z.object({
name: z.string().min(1, "Pflichtfeld"),
- vatId: z.string().optional(),
+ // vatId: z.string().optional(),
address: z.string().min(1, "Pflichtfeld"),
zip: z.string().min(1, "Pflichtfeld"),
city: z.string().min(1, "Pflichtfeld"),
@@ -35,7 +35,7 @@ type FormData = z.infer;
interface Customer {
id: string;
name: string;
- vatId?: string | null;
+ // vatId?: string | null;
address: string;
zip: string;
city: string;
@@ -93,10 +93,10 @@ function CustomerForm({
- */}
@@ -185,7 +185,6 @@ export default function CustomersPage() {
address: editCustomer.address,
zip: editCustomer.zip,
city: editCustomer.city,
- vatId: editCustomer.vatId ?? undefined,
email: editCustomer.email ?? undefined,
phone: editCustomer.phone ?? undefined,
}}
@@ -212,7 +211,7 @@ export default function CustomersPage() {
{customer.name}
{customer.address}, {customer.zip} {customer.city}
- {customer.vatId &&
USt-IdNr.: {customer.vatId}
}
+ {/* {customer.vatId &&
USt-IdNr.: {customer.vatId}
} */}
{customer.email && (
diff --git a/db/docker-compose.yml b/db/docker-compose.yml
new file mode 100644
index 0000000..2930f4f
--- /dev/null
+++ b/db/docker-compose.yml
@@ -0,0 +1,47 @@
+services:
+ mariadb:
+ image: mariadb:11
+ container_name: annas_mariadb
+ restart: unless-stopped
+ environment:
+ MYSQL_ROOT_PASSWORD: rootpassword
+ MYSQL_DATABASE: annas_rechnungen
+ MYSQL_USER: annas_user
+ MYSQL_PASSWORD: annas_password
+ volumes:
+ - mariadb_data:/var/lib/mysql
+ ports:
+ - "3306:3306"
+ networks:
+ - db_network
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ start_period: 10s
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ phpmyadmin:
+ image: phpmyadmin:latest
+ container_name: annas_phpmyadmin
+ restart: unless-stopped
+ environment:
+ PMA_HOST: mariadb
+ PMA_PORT: 3306
+ PMA_USER: root
+ PMA_PASSWORD: rootpassword
+ UPLOAD_LIMIT: 100M
+ ports:
+ - "8080:80"
+ depends_on:
+ mariadb:
+ condition: service_healthy
+ networks:
+ - db_network
+
+volumes:
+ mariadb_data:
+
+networks:
+ db_network:
+ driver: bridge
diff --git a/docker-compose.yml b/docker-compose.yml
index 2930f4f..9d07df7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,10 +10,8 @@ services:
MYSQL_PASSWORD: annas_password
volumes:
- mariadb_data:/var/lib/mysql
- ports:
- - "3306:3306"
networks:
- - db_network
+ - app_network
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
@@ -21,27 +19,27 @@ services:
timeout: 5s
retries: 5
- phpmyadmin:
- image: phpmyadmin:latest
- container_name: annas_phpmyadmin
+ app:
+ build: .
+ container_name: annas_app
restart: unless-stopped
- environment:
- PMA_HOST: mariadb
- PMA_PORT: 3306
- PMA_USER: root
- PMA_PASSWORD: rootpassword
- UPLOAD_LIMIT: 100M
ports:
- - "8080:80"
+ - "3000:3000"
+ environment:
+ DATABASE_URL: mysql://annas_user:annas_password@mariadb:3306/annas_rechnungen
+ AUTH_SECRET: ${AUTH_SECRET}
+ NODE_ENV: production
depends_on:
mariadb:
condition: service_healthy
networks:
- - db_network
+ - app_network
+ command: >
+ sh -c "npx prisma migrate deploy && npm run start"
volumes:
mariadb_data:
networks:
- db_network:
+ app_network:
driver: bridge
diff --git a/k8s.yml b/k8s.yml
new file mode 100644
index 0000000..51cf876
--- /dev/null
+++ b/k8s.yml
@@ -0,0 +1,198 @@
+---
+# Namespace
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: annas-rechnungsmanager
+
+---
+# Secret
+apiVersion: v1
+kind: Secret
+metadata:
+ name: annas-secrets
+ namespace: annas-rechnungsmanager
+type: Opaque
+stringData:
+ db-root-password: rootpassword
+ db-password: annas_password
+ auth-secret: your-random-secret-here
+ database-url: mysql://annas_user:annas_password@mariadb-service:3306/annas_rechnungen
+
+---
+# MariaDB PersistentVolumeClaim
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: mariadb-pvc
+ namespace: annas-rechnungsmanager
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+
+---
+# MariaDB Deployment
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: mariadb
+ namespace: annas-rechnungsmanager
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: mariadb
+ template:
+ metadata:
+ labels:
+ app: mariadb
+ spec:
+ containers:
+ - name: mariadb
+ image: mariadb:11
+ ports:
+ - containerPort: 3306
+ env:
+ - name: MYSQL_ROOT_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: annas-secrets
+ key: db-root-password
+ - name: MYSQL_DATABASE
+ value: annas_rechnungen
+ - name: MYSQL_USER
+ value: annas_user
+ - name: MYSQL_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: annas-secrets
+ key: db-password
+ volumeMounts:
+ - name: mariadb-storage
+ mountPath: /var/lib/mysql
+ livenessProbe:
+ exec:
+ command: ["healthcheck.sh", "--connect", "--innodb_initialized"]
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ readinessProbe:
+ exec:
+ command: ["healthcheck.sh", "--connect", "--innodb_initialized"]
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ volumes:
+ - name: mariadb-storage
+ persistentVolumeClaim:
+ claimName: mariadb-pvc
+
+---
+# MariaDB Service
+apiVersion: v1
+kind: Service
+metadata:
+ name: mariadb-service
+ namespace: annas-rechnungsmanager
+spec:
+ selector:
+ app: mariadb
+ ports:
+ - port: 3306
+ targetPort: 3306
+
+---
+# App Deployment
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: annas-app
+ namespace: annas-rechnungsmanager
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: annas-app
+ template:
+ metadata:
+ labels:
+ app: annas-app
+ spec:
+ initContainers:
+ - name: migrate
+ image: annas-rechnungsmanager:latest
+ command: ["npx", "prisma", "migrate", "deploy"]
+ env:
+ - name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: annas-secrets
+ key: database-url
+ containers:
+ - name: annas-app
+ image: annas-rechnungsmanager:latest
+ ports:
+ - containerPort: 3000
+ env:
+ - name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: annas-secrets
+ key: database-url
+ - name: AUTH_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: annas-secrets
+ key: auth-secret
+ - name: NODE_ENV
+ value: production
+ readinessProbe:
+ httpGet:
+ path: /
+ port: 3000
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ resources:
+ requests:
+ memory: 256Mi
+ cpu: 250m
+ limits:
+ memory: 512Mi
+ cpu: 500m
+
+---
+# App Service
+apiVersion: v1
+kind: Service
+metadata:
+ name: annas-app-service
+ namespace: annas-rechnungsmanager
+spec:
+ selector:
+ app: annas-app
+ ports:
+ - port: 80
+ targetPort: 3000
+
+---
+# Ingress
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: annas-app-ingress
+ namespace: annas-rechnungsmanager
+ annotations:
+ nginx.ingress.kubernetes.io/rewrite-target: /
+spec:
+ rules:
+ - host: rechnungsmanager.local
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: annas-app-service
+ port:
+ number: 80
diff --git a/package.json b/package.json
index b81944c..9629416 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "docker-compose up -d && react-router dev",
+ "dev": "docker-compose -f db/docker-compose.yml up -d && react-router dev",
"build": "react-router build",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc",
diff --git a/prisma/migrations/20260311212434_remove_vatid_from_customer/migration.sql b/prisma/migrations/20260311212434_remove_vatid_from_customer/migration.sql
new file mode 100644
index 0000000..1ffede7
--- /dev/null
+++ b/prisma/migrations/20260311212434_remove_vatid_from_customer/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `vatId` on the `customers` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE `customers` DROP COLUMN `vatId`;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f3bfc0e..fe59189 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -52,7 +52,6 @@ model Customer {
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
name String
- vatId String?
taxId String?
address String
zip String