ADD: added dockerfile and docker-compose and k8s manifest

This commit is contained in:
hwinkel
2026-03-11 22:37:38 +01:00
parent 1ac4fae943
commit f9307d9f4a
14 changed files with 399 additions and 49 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
build
.env
.git
*.md
src/
+31
View File
@@ -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"]
+64 -21
View File
@@ -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)
+26 -2
View File
@@ -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 }) {
)}
</nav>
{/* Dashboard Button */}
{!isOnDashboard && (
<Link
to="/"
style={{
display: "flex",
alignItems: "center",
gap: "0.375rem",
fontSize: "0.875rem",
color: "#64748b",
textDecoration: "none",
padding: "0.375rem 0.75rem",
borderRadius: "0.375rem",
border: "1px solid #e2e8f0",
flexShrink: 0,
}}
>
<LayoutDashboard className="h-4 w-4" />
Dashboard
</Link>
)}
{/* User */}
{userName && (
<div style={{ display: "flex", alignItems: "center", gap: "0.625rem", marginLeft: "1rem", flexShrink: 0 }}>
-1
View File
@@ -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),
-1
View File
@@ -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),
-1
View File
@@ -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),
+5 -6
View File
@@ -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<typeof schema>;
interface Customer {
id: string;
name: string;
vatId?: string | null;
// vatId?: string | null;
address: string;
zip: string;
city: string;
@@ -93,10 +93,10 @@ function CustomerForm({
<Label>Ort *</Label>
<Input {...register("city")} placeholder="Berlin" />
</div>
<div className="space-y-1.5">
{/* <div className="space-y-1.5">
<Label>USt-IdNr.</Label>
<Input {...register("vatId")} placeholder="DE..." />
</div>
</div> */}
<div className="space-y-1.5">
<Label>E-Mail</Label>
<Input {...register("email")} type="email" placeholder="kontakt@..." />
@@ -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() {
<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>}
{/* {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">
+47
View File
@@ -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
+13 -15
View File
@@ -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
+198
View File
@@ -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
+1 -1
View File
@@ -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",
@@ -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`;
-1
View File
@@ -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