ADD: added dockerfile and docker-compose and k8s manifest
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
build
|
||||
.env
|
||||
.git
|
||||
*.md
|
||||
src/
|
||||
+31
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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`;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user