From f9307d9f4a85ec78014f83171916289689b095b4 Mon Sep 17 00:00:00 2001 From: hwinkel Date: Wed, 11 Mar 2026 22:37:38 +0100 Subject: [PATCH] ADD: added dockerfile and docker-compose and k8s manifest --- .dockerignore | 6 + Dockerfile | 31 +++ README.md | 85 ++++++-- app/components/layout/topbar.tsx | 28 ++- app/routes/api.companies.ts | 1 - app/routes/api.customers.$id.ts | 1 - app/routes/api.customers.ts | 1 - app/routes/companies.$id.customers.tsx | 11 +- db/docker-compose.yml | 47 +++++ docker-compose.yml | 28 ++- k8s.yml | 198 ++++++++++++++++++ package.json | 2 +- .../migration.sql | 8 + prisma/schema.prisma | 1 - 14 files changed, 399 insertions(+), 49 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 db/docker-compose.yml create mode 100644 k8s.yml create mode 100644 prisma/migrations/20260311212434_remove_vatid_from_customer/migration.sql 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