From 13cef3c96dae04c9aa6701a184c1d3ec2fbd47a1 Mon Sep 17 00:00:00 2001 From: Fidelis Huber Date: Fri, 21 Aug 2026 17:24:38 +0200 Subject: [PATCH] Initial scaffold: React/Vite/TS PWA + Express/Prisma/Postgres backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Materialschein & Kabelrechner für BergVOLT Elektrotechnik, portiert von der vorherigen Single-File-HTML-Version. Microsoft-Entra-ID-Login (MSAL), Projekt- Dashboard statt einer Datei pro Kunde, Retour-Buchen-Zusammenführung, 111 Kabeltypen aus den offiziellen Meinhart-Datenblättern. gitops/ nach PROX/deploy-standard: git-backed Portainer-Stack, versionierte Docker-Images, CI-Commit-back auf gitops/stack.yml. Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/build.yml | 80 ++++ .gitignore | 8 + Dockerfile | 29 ++ README.md | 96 +++++ client/index.html | 15 + client/package.json | 26 ++ client/public/icons/apple-touch-icon.png | Bin 0 -> 7268 bytes client/public/icons/favicon-32.png | Bin 0 -> 920 bytes client/public/icons/icon-192.png | Bin 0 -> 7899 bytes client/public/icons/icon-512.png | Bin 0 -> 27355 bytes client/src/App.tsx | 29 ++ client/src/components/Header.tsx | 36 ++ client/src/components/KabelOptions.tsx | 28 ++ client/src/data/kabelData.ts | 75 ++++ client/src/lib/api.ts | 36 ++ client/src/lib/authConfig.ts | 19 + client/src/lib/types.ts | 26 ++ client/src/main.tsx | 16 + client/src/pages/Dashboard.tsx | 116 ++++++ client/src/pages/Kabelrechner.tsx | 104 +++++ client/src/pages/Login.tsx | 40 ++ client/src/pages/ProjektDetail.tsx | 390 ++++++++++++++++++ client/src/styles.css | 189 +++++++++ client/src/vite-env.d.ts | 1 + client/tsconfig.json | 16 + client/vite.config.ts | 45 ++ docker-compose.dev.yml | 15 + gitops/stack.yml | 38 ++ gitops/version | 1 + server/package.json | 29 ++ .../20260821120000_init/migration.sql | 38 ++ server/prisma/migrations/migration_lock.toml | 3 + server/prisma/schema.prisma | 44 ++ server/src/auth.ts | 54 +++ server/src/index.ts | 32 ++ server/src/prisma.ts | 3 + server/src/routes/material.ts | 60 +++ server/src/routes/projekte.ts | 46 +++ server/tsconfig.json | 15 + 39 files changed, 1798 insertions(+) create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 client/index.html create mode 100644 client/package.json create mode 100644 client/public/icons/apple-touch-icon.png create mode 100644 client/public/icons/favicon-32.png create mode 100644 client/public/icons/icon-192.png create mode 100644 client/public/icons/icon-512.png create mode 100644 client/src/App.tsx create mode 100644 client/src/components/Header.tsx create mode 100644 client/src/components/KabelOptions.tsx create mode 100644 client/src/data/kabelData.ts create mode 100644 client/src/lib/api.ts create mode 100644 client/src/lib/authConfig.ts create mode 100644 client/src/lib/types.ts create mode 100644 client/src/main.tsx create mode 100644 client/src/pages/Dashboard.tsx create mode 100644 client/src/pages/Kabelrechner.tsx create mode 100644 client/src/pages/Login.tsx create mode 100644 client/src/pages/ProjektDetail.tsx create mode 100644 client/src/styles.css create mode 100644 client/src/vite-env.d.ts create mode 100644 client/tsconfig.json create mode 100644 client/vite.config.ts create mode 100644 docker-compose.dev.yml create mode 100644 gitops/stack.yml create mode 100644 gitops/version create mode 100644 server/package.json create mode 100644 server/prisma/migrations/20260821120000_init/migration.sql create mode 100644 server/prisma/migrations/migration_lock.toml create mode 100644 server/prisma/schema.prisma create mode 100644 server/src/auth.ts create mode 100644 server/src/index.ts create mode 100644 server/src/prisma.ts create mode 100644 server/src/routes/material.ts create mode 100644 server/src/routes/projekte.ts create mode 100644 server/tsconfig.json diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..1e433fd --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,80 @@ +name: Build and Deploy + +# Standard-Workflow git.prox.ws → Portainer (siehe deploy-standard-Repo README). +# owner = bergvolt (Package-Namespace == Repo-Owner, lowercase für OCI), name = materialschein. +# +# Versionierung: VERSION = .. +# Ein version-Job berechnet sie, build + deploy konsumieren sie über +# needs.version.outputs.version. Der deploy-Job pinnt gitops/stack.yml per +# Commit-back auf : — nie floating :latest. + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + version: + runs-on: linux + outputs: + version: ${{ steps.v.outputs.VERSION }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: v + run: | + BASE=$(cat gitops/version | tr -d '[:space:]') + PATCH=$(git rev-list --count HEAD) + echo "VERSION=${BASE}.${PATCH}" >> "$GITHUB_OUTPUT" + echo "version ${BASE}.${PATCH}" + + build: + runs-on: linux + needs: version + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Login to Gitea Registry + run: | + echo "${{ secrets.GITEAPASSWORD }}" | docker login -u ${{ secrets.GITEAUSER }} --password-stdin https://git.prox.ws + + - name: Build and Push + run: | + V=${{ needs.version.outputs.version }} + docker build \ + -t git.prox.ws/bergvolt/materialschein:$V \ + -t git.prox.ws/bergvolt/materialschein:latest \ + . + docker push git.prox.ws/bergvolt/materialschein:$V + docker push git.prox.ws/bergvolt/materialschein:latest + + deploy: + runs-on: linux + needs: [version, build] + steps: + - name: Checkout main (mit Push-Recht) + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + token: ${{ secrets.GITEA_TOKEN }} + + - name: Pin stack to version + commit-back + run: | + V=${{ needs.version.outputs.version }} + sed -i -E "s#(image: git\.prox\.ws/[^:[:space:]]+):[^[:space:]]+#\1:$V#" gitops/stack.yml + if git diff --quiet gitops/stack.yml; then + echo "stack.yml schon auf $V, kein Commit-back" + else + git config user.name "gitea-actions" + git config user.email "actions@git.prox.ws" + git commit -am "deploy: v$V [skip ci]" + git push + fi + + - name: Trigger Portainer Stack Redeploy + run: | + test -n "${{ secrets.PORTAINER_WEBHOOK_URL }}" || { echo "secret PORTAINER_WEBHOOK_URL is not set"; exit 1; } + curl -fsSk --retry 3 --retry-delay 5 -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d873d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +client/dist/ +server/dist/ +.env +.env.local +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9ccf6e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# ── Frontend bauen (React + Vite + TS + PWA) ── +FROM node:20-alpine AS client-build +WORKDIR /app/client +COPY client/package*.json ./ +RUN npm ci +COPY client/ ./ +RUN npm run build + +# ── Backend bauen (Express + TS + Prisma) ── +FROM node:20-alpine AS server-build +WORKDIR /app/server +COPY server/package*.json ./ +RUN npm ci +COPY server/ ./ +RUN npx prisma generate +RUN npm run build + +# ── Laufzeit-Image ── +FROM node:20-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY --from=server-build /app/server/node_modules ./node_modules +COPY --from=server-build /app/server/dist ./dist +COPY --from=server-build /app/server/prisma ./prisma +COPY --from=server-build /app/server/package.json ./package.json +COPY --from=client-build /app/client/dist ./public + +EXPOSE 80 +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d51ff0d --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# BergVOLT Materialschein + +Materialschein & Kabelrechner für BergVOLT Elektrotechnik. React + Vite + TypeScript +(PWA) im Client, Express + TypeScript + Prisma im Server, Postgres als Datenbank. +Anmeldung über Microsoft Entra ID (gleiche App-Registrierung wie das Zeiterfassungs-Tool). + +## Lokale Entwicklung + +```bash +# 1. Postgres lokal starten +docker compose -f docker-compose.dev.yml up -d + +# 2. Server +cd server +npm install +echo 'DATABASE_URL="postgresql://materialschein:dev@localhost:5433/materialschein"' > .env +echo 'MSAL_CLIENT_ID="b1cd974c-77ce-4d93-b6e8-00e41a31adc8"' >> .env +echo 'MSAL_TENANT_ID="d9adeaaf-853b-4c4e-a822-8f1bedbb84f6"' >> .env +echo 'PORT=8787' >> .env +npx prisma migrate deploy +npm run dev # läuft auf :8787 + +# 3. Client (neues Terminal) +cd client +npm install +npm run dev # läuft auf :5173, proxied /api auf :8787 +``` + +## Architektur + +- `client/` – React 18 + Vite + TypeScript, `vite-plugin-pwa` für Manifest/Service Worker. + MSAL.js (`@azure/msal-browser` + `@azure/msal-react`) für den Microsoft-Login. +- `server/` – Express + TypeScript, validiert das MSAL-ID-Token serverseitig (JWKS von + `login.microsoftonline.com`) und liefert zusätzlich die gebaute Client-App aus + (`public/` = `client/dist`, SPA-Fallback für React-Router). +- `server/prisma/schema.prisma` – zwei Tabellen: `projekte` (Baustellen) und + `material` (Positionen je Projekt, per `projektId` verknüpft). +- Ein Docker-Image (`Dockerfile`, Multi-Stage) enthält Client-Build + Server, Port 80. + +## Deploy (git.prox.ws → Portainer) + +Folgt [deploy-standard](https://git.prox.ws/PROX/deploy-standard) 1:1 – `gitops/stack.yml` +und `.gitea/workflows/build.yml` sind bereits fertig im Repo. Zum Scharfschalten: + +### 1. Azure AD – zweite Redirect-URI eintragen +Bestehende App-Registrierung "BergVolt Zeiterfassung" im Azure Portal öffnen → +Authentifizierung → bei "Single-Page-Anwendung" ergänzen: +`https://materialschein.bergvolt.at` (oder die tatsächlich vorgesehene Domain/Pfad). +**Redirect-URI in `client/src/lib/authConfig.ts` steht auf `window.location.origin` – +d.h. die exakte Domain, unter der die App später erreichbar ist, muss hier eingetragen werden.** + +### 2. Gitea – Repo-Secrets setzen +Repo → Settings → Actions → Secrets: + +| Secret | Wert | +|---|---| +| `GITEAUSER` | Gitea-Username des Package-Namespace-Owners (siehe deploy-standard: Package-Push geht nur als Namespace-Owner) | +| `GITEAPASSWORD` | PAT dieses Users, Scope **nur `write:package`** (Settings → Applications → Generate New Token) | +| `PORTAINER_WEBHOOK_URL` | kommt aus Schritt 4 | + +Repo → Settings → Actions: Default-Permission auf **Read and Write**, damit der +Commit-back (`gitops/stack.yml` auf `:` pinnen) funktioniert. + +### 3. Erster Push +```bash +git push origin main +``` +`build` wird grün, `deploy` schlägt fehl (Webhook-Secret fehlt noch) – erwartet. + +### 4. Portainer – Stack anlegen +Auf `https://10.1.10.111:9443` (Zugang laut deploy-standard bei "dominic"): + +- Stacks → Add stack → **Repository** + - Repository URL: `https://git.prox.ws/BERGVOLT/Materialschein` + - Reference: `refs/heads/main` + - Compose path: `gitops/stack.yml` + - Authentication: `portainer-deploy` + dessen PAT (Org-Repos sind über das + `deploy-read`-Team bereits abgedeckt, nichts zusätzlich einzutragen) + - Environment-Variable setzen: `POSTGRES_PASSWORD` = ein zufälliges, sicheres Passwort + - **Webhook aktivieren**, **ForceUpdate an**, `ForcePullImage` bleibt aus +- Die angezeigte Webhook-URL (`.../api/stacks/webhooks/`) als Gitea-Secret + `PORTAINER_WEBHOOK_URL` eintragen (Schritt 2). +- nginx-proxy-manager: Proxy Host auf Container `materialschein`, Port `80`. + +### 5. Verifizieren +- Webhook von Hand feuern (`curl -sk -X POST `) → `204`, Container laufen + (`db` + `web`). +- Push oder „Run workflow" auslösen → beide Jobs grün, `gitops/stack.yml` zeigt danach + `image: …:1.0.` (kein `:latest` mehr), Commit `deploy: v1.0. [skip ci]` im Log. +- Cleanup-Rule für den Package-Owner einmalig setzen (Org/User → Settings → Packages → + Cleanup Rules): keep most recent 10 + keep matching `^latest$`. + +### Danach: zum Homescreen hinzufügen +Seite im Handy-Browser öffnen, anmelden, "Zum Home-Bildschirm hinzufügen" (iOS Safari) +bzw. "App installieren" (Android Chrome) – die App ist als PWA installierbar +(`vite-plugin-pwa` generiert Manifest + Service Worker automatisch beim Build). diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..de56f29 --- /dev/null +++ b/client/index.html @@ -0,0 +1,15 @@ + + + + + + + + +BergVOLT Elektrotechnik – Materialschein + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..5223455 --- /dev/null +++ b/client/package.json @@ -0,0 +1,26 @@ +{ + "name": "materialschein-client", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@azure/msal-browser": "^3.27.0", + "@azure/msal-react": "^2.1.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/react": "^18.3.9", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "typescript": "^5.6.2", + "vite": "^5.4.8", + "vite-plugin-pwa": "^0.20.5" + } +} diff --git a/client/public/icons/apple-touch-icon.png b/client/public/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cba01921024af0a8ef495d23daaebf18dfdea1bd GIT binary patch literal 7268 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 ZN|}SF!#T2~fnNqeI-1us^3^Ot{|~e^Mj!wH literal 0 HcmV?d00001 diff --git a/client/public/icons/favicon-32.png b/client/public/icons/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..38974cba1ae01d7f4fce9ecfcef88eff3412d764 GIT binary patch literal 920 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 literal 0 HcmV?d00001 diff --git a/client/public/icons/icon-512.png b/client/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..079669d73a224c1f2a308efdcaf14e1a479c7fbd GIT binary patch literal 27355 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

;QE$KT;Ih&{ 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 literal 0 HcmV?d00001 diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..e729625 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,29 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { AuthenticatedTemplate, UnauthenticatedTemplate } from '@azure/msal-react'; +import Login from './pages/Login'; +import Header from './components/Header'; +import Dashboard from './pages/Dashboard'; +import ProjektDetail from './pages/ProjektDetail'; +import Kabelrechner from './pages/Kabelrechner'; + +export default function App() { + return ( + <> + + + + + +

+
+ + } /> + } /> + } /> + +
+ + + + ); +} diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx new file mode 100644 index 0000000..78122bd --- /dev/null +++ b/client/src/components/Header.tsx @@ -0,0 +1,36 @@ +import { Link, useLocation } from 'react-router-dom'; +import { useMsal } from '@azure/msal-react'; + +export default function Header() { + const { instance, accounts } = useMsal(); + const location = useLocation(); + const onKabelrechner = location.pathname.startsWith('/kabelrechner'); + const account = accounts[0]; + const displayName = account?.name || account?.username || '–'; + const initials = displayName.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase(); + + return ( + <> +
+
+
+
BergVOLT
+
Elektrotechnik
+
+
+
+
{initials}
+
+
{displayName.split(' ')[0]}
+
{account?.username}
+
+
+ +
+
+ 📋 MATERIALSCHEIN + ⚖️ KABELRECHNER +
+ + ); +} diff --git a/client/src/components/KabelOptions.tsx b/client/src/components/KabelOptions.tsx new file mode 100644 index 0000000..674268f --- /dev/null +++ b/client/src/components/KabelOptions.tsx @@ -0,0 +1,28 @@ +import { KAB_DATA, type KabelTyp } from '../data/kabelData'; + +const GROUPS: { label: string; test: (c: KabelTyp) => boolean }[] = [ + { label: 'NYM – Mantelleitung', test: c => c.typ === 'NYM' }, + { label: 'NYY – Starkstromkabel 0,6/1kV', test: c => c.typ === 'NYY' }, + { label: 'YSLY-JZ – Steuerleitung', test: c => c.typ === 'YSLY-JZ' }, + { label: 'Aderleitung starr (H05V-U / H07V-U)', test: c => c.typ === 'H05V-U' || c.typ === 'H07V-U' }, + { label: 'Aderleitung flexibel (H05V-K / H07V-K)', test: c => c.typ === 'H05V-K' || c.typ === 'H07V-K' }, + { label: 'H05VV-F / A05VV-F – Schlauchleitung', test: c => c.typ === 'H05VV-F' || c.typ === 'A05VV-F' } +]; + +// mode "combo": Value = "Name|kgpm" (für die Materialliste – Bezeichnung braucht den Namen). +// mode "kgpm": Value = reine kg/m-Zahl (für den Kabelrechner, der nur mit dem Faktor rechnet). +export default function KabelOptions({ mode = 'combo' }: { mode?: 'combo' | 'kgpm' }) { + return ( + <> + {GROUPS.map(g => ( + + {KAB_DATA.filter(g.test).map(c => ( + + ))} + + ))} + + ); +} diff --git a/client/src/data/kabelData.ts b/client/src/data/kabelData.ts new file mode 100644 index 0000000..e186faa --- /dev/null +++ b/client/src/data/kabelData.ts @@ -0,0 +1,75 @@ +export interface KabelTyp { + typ: string; + name: string; + kgpm: number; + badge: 'nym' | 'nyy' | 'h07' | 'ysly' | 'schlauch'; +} + +// Alle Werte aus den offiziellen Meinhart-Kabel-Datenblättern (Meinhart Kabel Österreich GmbH). +export const KAB_DATA: KabelTyp[] = [ + { typ: 'NYM', name: 'NYM 3×1,5 mm²', kgpm: 0.121, badge: 'nym' }, { typ: 'NYM', name: 'NYM 3×2,5 mm²', kgpm: 0.170, badge: 'nym' }, + { typ: 'NYM', name: 'NYM 3×4 mm²', kgpm: 0.241, badge: 'nym' }, { typ: 'NYM', name: 'NYM 3×6 mm²', kgpm: 0.328, badge: 'nym' }, + { typ: 'NYM', name: 'NYM 4×1,5 mm²', kgpm: 0.144, badge: 'nym' }, { typ: 'NYM', name: 'NYM 4×2,5 mm²', kgpm: 0.206, badge: 'nym' }, + { typ: 'NYM', name: 'NYM 4×4 mm²', kgpm: 0.305, badge: 'nym' }, { typ: 'NYM', name: 'NYM 4×6 mm²', kgpm: 0.400, badge: 'nym' }, + { typ: 'NYM', name: 'NYM 4×10 mm²', kgpm: 0.622, badge: 'nym' }, { typ: 'NYM', name: 'NYM 4×16 mm²', kgpm: 0.924, badge: 'nym' }, + { typ: 'NYM', name: 'NYM 5×1,5 mm²', kgpm: 0.168, badge: 'nym' }, { typ: 'NYM', name: 'NYM 5×2,5 mm²', kgpm: 0.242, badge: 'nym' }, + { typ: 'NYM', name: 'NYM 5×4 mm²', kgpm: 0.360, badge: 'nym' }, { typ: 'NYM', name: 'NYM 5×6 mm²', kgpm: 0.476, badge: 'nym' }, + { typ: 'NYM', name: 'NYM 5×10 mm²', kgpm: 0.744, badge: 'nym' }, { typ: 'NYM', name: 'NYM 5×16 mm²', kgpm: 1.145, badge: 'nym' }, + { typ: 'NYY', name: 'NYY 3×1,5 mm²', kgpm: 0.244, badge: 'nyy' }, { typ: 'NYY', name: 'NYY 3×2,5 mm²', kgpm: 0.294, badge: 'nyy' }, + { typ: 'NYY', name: 'NYY 3×4 mm²', kgpm: 0.393, badge: 'nyy' }, { typ: 'NYY', name: 'NYY 3×6 mm²', kgpm: 0.481, badge: 'nyy' }, + { typ: 'NYY', name: 'NYY 3×10 mm²', kgpm: 0.645, badge: 'nyy' }, { typ: 'NYY', name: 'NYY 3×16 mm²', kgpm: 0.872, badge: 'nyy' }, + { typ: 'NYY', name: 'NYY 3×25 mm²', kgpm: 1.350, badge: 'nyy' }, { typ: 'NYY', name: 'NYY 4×1,5 mm²', kgpm: 0.278, badge: 'nyy' }, + { typ: 'NYY', name: 'NYY 4×2,5 mm²', kgpm: 0.340, badge: 'nyy' }, { typ: 'NYY', name: 'NYY 4×4 mm²', kgpm: 0.460, badge: 'nyy' }, + { typ: 'NYY', name: 'NYY 4×6 mm²', kgpm: 0.570, badge: 'nyy' }, { typ: 'NYY', name: 'NYY 4×10 mm²', kgpm: 0.775, badge: 'nyy' }, + { typ: 'NYY', name: 'NYY 4×16 mm²', kgpm: 1.072, badge: 'nyy' }, { typ: 'NYY', name: 'NYY 4×25 mm²', kgpm: 1.632, badge: 'nyy' }, + { typ: 'NYY', name: 'NYY 5×1,5 mm²', kgpm: 0.317, badge: 'nyy' }, { typ: 'NYY', name: 'NYY 5×2,5 mm²', kgpm: 0.391, badge: 'nyy' }, + { typ: 'NYY', name: 'NYY 5×4 mm²', kgpm: 0.537, badge: 'nyy' }, { typ: 'NYY', name: 'NYY 5×6 mm²', kgpm: 0.672, badge: 'nyy' }, + { typ: 'NYY', name: 'NYY 5×10 mm²', kgpm: 0.921, badge: 'nyy' }, { typ: 'NYY', name: 'NYY 5×16 mm²', kgpm: 1.294, badge: 'nyy' }, + { typ: 'NYY', name: 'NYY 5×25 mm²', kgpm: 2.004, badge: 'nyy' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 2×0,5 mm²', kgpm: 0.035, badge: 'ysly' }, { typ: 'YSLY-JZ', name: 'YSLY-JZ 3×0,5 mm²', kgpm: 0.041, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 4×0,5 mm²', kgpm: 0.049, badge: 'ysly' }, { typ: 'YSLY-JZ', name: 'YSLY-JZ 5×0,5 mm²', kgpm: 0.060, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 7×0,5 mm²', kgpm: 0.077, badge: 'ysly' }, { typ: 'YSLY-JZ', name: 'YSLY-JZ 12×0,5 mm²', kgpm: 0.128, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 3×0,75 mm²', kgpm: 0.050, badge: 'ysly' }, { typ: 'YSLY-JZ', name: 'YSLY-JZ 4×0,75 mm²', kgpm: 0.064, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 5×0,75 mm²', kgpm: 0.077, badge: 'ysly' }, { typ: 'YSLY-JZ', name: 'YSLY-JZ 7×0,75 mm²', kgpm: 0.099, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 12×0,75 mm²', kgpm: 0.165, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 3×1 mm²', kgpm: 0.061, badge: 'ysly' }, { typ: 'YSLY-JZ', name: 'YSLY-JZ 4×1 mm²', kgpm: 0.075, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 5×1 mm²', kgpm: 0.095, badge: 'ysly' }, { typ: 'YSLY-JZ', name: 'YSLY-JZ 7×1 mm²', kgpm: 0.114, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 3×1,5 mm²', kgpm: 0.079, badge: 'ysly' }, { typ: 'YSLY-JZ', name: 'YSLY-JZ 4×1,5 mm²', kgpm: 0.098, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 5×1,5 mm²', kgpm: 0.123, badge: 'ysly' }, { typ: 'YSLY-JZ', name: 'YSLY-JZ 7×1,5 mm²', kgpm: 0.161, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 12×1,5 mm²', kgpm: 0.277, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 3×2,5 mm²', kgpm: 0.127, badge: 'ysly' }, { typ: 'YSLY-JZ', name: 'YSLY-JZ 4×2,5 mm²', kgpm: 0.160, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 5×2,5 mm²', kgpm: 0.197, badge: 'ysly' }, { typ: 'YSLY-JZ', name: 'YSLY-JZ 7×2,5 mm²', kgpm: 0.256, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 3×4 mm²', kgpm: 0.181, badge: 'ysly' }, { typ: 'YSLY-JZ', name: 'YSLY-JZ 4×4 mm²', kgpm: 0.230, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 5×4 mm²', kgpm: 0.287, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 3×6 mm²', kgpm: 0.272, badge: 'ysly' }, { typ: 'YSLY-JZ', name: 'YSLY-JZ 4×6 mm²', kgpm: 0.353, badge: 'ysly' }, + { typ: 'YSLY-JZ', name: 'YSLY-JZ 5×6 mm²', kgpm: 0.431, badge: 'ysly' }, + { typ: 'H05V-U', name: 'H05V-U 1×0,5 mm²', kgpm: 0.009, badge: 'h07' }, { typ: 'H05V-U', name: 'H05V-U 1×0,75 mm²', kgpm: 0.012, badge: 'h07' }, + { typ: 'H05V-U', name: 'H05V-U 1×1 mm²', kgpm: 0.014, badge: 'h07' }, + { typ: 'H07V-U', name: 'H07V-U 1×1,5 mm²', kgpm: 0.020, badge: 'h07' }, { typ: 'H07V-U', name: 'H07V-U 1×2,5 mm²', kgpm: 0.031, badge: 'h07' }, + { typ: 'H07V-U', name: 'H07V-U 1×4 mm²', kgpm: 0.046, badge: 'h07' }, { typ: 'H07V-U', name: 'H07V-U 1×6 mm²', kgpm: 0.065, badge: 'h07' }, + { typ: 'H07V-U', name: 'H07V-U 1×10 mm²', kgpm: 0.109, badge: 'h07' }, + { typ: 'H05V-K', name: 'H05V-K 1×0,5 mm²', kgpm: 0.010, badge: 'h07' }, { typ: 'H05V-K', name: 'H05V-K 1×0,75 mm²', kgpm: 0.012, badge: 'h07' }, + { typ: 'H05V-K', name: 'H05V-K 1×1 mm²', kgpm: 0.014, badge: 'h07' }, + { typ: 'H07V-K', name: 'H07V-K 1×1,5 mm²', kgpm: 0.021, badge: 'h07' }, { typ: 'H07V-K', name: 'H07V-K 1×2,5 mm²', kgpm: 0.032, badge: 'h07' }, + { typ: 'H07V-K', name: 'H07V-K 1×4 mm²', kgpm: 0.047, badge: 'h07' }, { typ: 'H07V-K', name: 'H07V-K 1×6 mm²', kgpm: 0.067, badge: 'h07' }, + { typ: 'H07V-K', name: 'H07V-K 1×10 mm²', kgpm: 0.115, badge: 'h07' }, { typ: 'H07V-K', name: 'H07V-K 1×16 mm²', kgpm: 0.175, badge: 'h07' }, + { typ: 'H07V-K', name: 'H07V-K 1×25 mm²', kgpm: 0.280, badge: 'h07' }, { typ: 'H07V-K', name: 'H07V-K 1×35 mm²', kgpm: 0.375, badge: 'h07' }, + { typ: 'H07V-K', name: 'H07V-K 1×50 mm²', kgpm: 0.550, badge: 'h07' }, { typ: 'H07V-K', name: 'H07V-K 1×70 mm²', kgpm: 0.760, badge: 'h07' }, + { typ: 'H07V-K', name: 'H07V-K 1×95 mm²', kgpm: 1.020, badge: 'h07' }, { typ: 'H07V-K', name: 'H07V-K 1×120 mm²', kgpm: 1.270, badge: 'h07' }, + { typ: 'H05VV-F', name: 'H05VV-F 2×1 mm²', kgpm: 0.067, badge: 'schlauch' }, { typ: 'H05VV-F', name: 'H05VV-F 2×1,5 mm²', kgpm: 0.089, badge: 'schlauch' }, + { typ: 'H05VV-F', name: 'H05VV-F 2×2,5 mm²', kgpm: 0.134, badge: 'schlauch' }, + { typ: 'H05VV-F', name: 'H05VV-F 3G0,75 mm²', kgpm: 0.064, badge: 'schlauch' }, { typ: 'H05VV-F', name: 'H05VV-F 3G1 mm²', kgpm: 0.080, badge: 'schlauch' }, + { typ: 'H05VV-F', name: 'H05VV-F 3G1,5 mm²', kgpm: 0.120, badge: 'schlauch' }, { typ: 'H05VV-F', name: 'H05VV-F 3G2,5 mm²', kgpm: 0.175, badge: 'schlauch' }, + { typ: 'H05VV-F', name: 'H05VV-F 4G1 mm²', kgpm: 0.094, badge: 'schlauch' }, { typ: 'H05VV-F', name: 'H05VV-F 4G1,5 mm²', kgpm: 0.130, badge: 'schlauch' }, + { typ: 'H05VV-F', name: 'H05VV-F 4G2,5 mm²', kgpm: 0.200, badge: 'schlauch' }, { typ: 'H05VV-F', name: 'H05VV-F 4G4 mm²', kgpm: 0.280, badge: 'schlauch' }, + { typ: 'H05VV-F', name: 'H05VV-F 5G0,75 mm²', kgpm: 0.100, badge: 'schlauch' }, { typ: 'H05VV-F', name: 'H05VV-F 5G1 mm²', kgpm: 0.120, badge: 'schlauch' }, + { typ: 'H05VV-F', name: 'H05VV-F 5G1,5 mm²', kgpm: 0.170, badge: 'schlauch' }, { typ: 'H05VV-F', name: 'H05VV-F 5G2,5 mm²', kgpm: 0.250, badge: 'schlauch' }, + { typ: 'H05VV-F', name: 'H05VV-F 5G4 mm²', kgpm: 0.350, badge: 'schlauch' }, + { typ: 'A05VV-F', name: 'A05VV-F 5G6 mm²', kgpm: 0.480, badge: 'schlauch' }, { typ: 'A05VV-F', name: 'A05VV-F 7G1 mm²', kgpm: 0.150, badge: 'schlauch' }, + { typ: 'A05VV-F', name: 'A05VV-F 7G1,5 mm²', kgpm: 0.196, badge: 'schlauch' }, { typ: 'A05VV-F', name: 'A05VV-F 7G2,5 mm²', kgpm: 0.315, badge: 'schlauch' }, + { typ: 'A05VV-F', name: 'A05VV-F 10G1,5 mm²', kgpm: 0.305, badge: 'schlauch' } +]; + +export function kabelInfoForBez(bez: string): KabelTyp | undefined { + return KAB_DATA.find(c => bez === 'Kabel ' + c.name); +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts new file mode 100644 index 0000000..1aace0c --- /dev/null +++ b/client/src/lib/api.ts @@ -0,0 +1,36 @@ +import { msalInstance } from './authConfig'; +import type { MaterialItem, Projekt } from './types'; + +async function authHeader(): Promise { + const account = msalInstance.getAllAccounts()[0]; + if (!account) throw new Error('Nicht angemeldet.'); + const result = await msalInstance.acquireTokenSilent({ scopes: ['openid', 'profile'], account }); + return { Authorization: `Bearer ${result.idToken}`, 'Content-Type': 'application/json' }; +} + +async function req(url: string, init?: RequestInit): Promise { + const headers = await authHeader(); + const res = await fetch(url, { ...init, headers: { ...headers, ...(init?.headers ?? {}) } }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `${res.status} ${res.statusText}`); + } + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} + +export const api = { + projekte: { + list: () => req('/api/projekte'), + create: (data: Partial) => req('/api/projekte', { method: 'POST', body: JSON.stringify(data) }), + update: (id: string, data: Partial) => req(`/api/projekte/${id}`, { method: 'PATCH', body: JSON.stringify(data) }) + }, + material: { + list: (projektId: string) => req(`/api/material?projektId=${encodeURIComponent(projektId)}`), + create: (data: { projektId: string; bezeichnung: string; artNr?: string; menge: number; einheit: string; notiz?: string }) => + req('/api/material', { method: 'POST', body: JSON.stringify(data) }), + update: (id: string, data: { menge?: number; notiz?: string }) => + req(`/api/material/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + remove: (id: string) => req(`/api/material/${id}`, { method: 'DELETE' }) + } +}; diff --git a/client/src/lib/authConfig.ts b/client/src/lib/authConfig.ts new file mode 100644 index 0000000..525c1db --- /dev/null +++ b/client/src/lib/authConfig.ts @@ -0,0 +1,19 @@ +import { PublicClientApplication, type Configuration } from '@azure/msal-browser'; + +// Gleiche Azure-AD-App-Registrierung wie die anderen BergVOLT-Tools (Zeiterfassung). +// Client-ID/Tenant-ID sind für einen öffentlichen SPA-Client nicht geheim. +export const MSAL_CLIENT_ID = 'b1cd974c-77ce-4d93-b6e8-00e41a31adc8'; +export const MSAL_TENANT_ID = 'd9adeaaf-853b-4c4e-a822-8f1bedbb84f6'; + +const msalConfig: Configuration = { + auth: { + clientId: MSAL_CLIENT_ID, + authority: `https://login.microsoftonline.com/${MSAL_TENANT_ID}`, + redirectUri: window.location.origin + }, + cache: { + cacheLocation: 'sessionStorage' + } +}; + +export const msalInstance = new PublicClientApplication(msalConfig); diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts new file mode 100644 index 0000000..0f1cdd3 --- /dev/null +++ b/client/src/lib/types.ts @@ -0,0 +1,26 @@ +export type ProjektStatus = 'Aktiv' | 'Abgeschlossen'; + +export interface Projekt { + id: string; + titel: string; + auftraggeber: string | null; + datum: string | null; + monteur: string | null; + notiz: string | null; + status: ProjektStatus; + createdAt: string; + updatedAt: string; +} + +export interface MaterialItem { + id: string; + projektId: string; + bezeichnung: string; + artNr: string | null; + menge: string; // Prisma Decimal kommt als String über JSON + einheit: string; + notiz: string | null; + erstelltVon: string | null; + createdAt: string; + selected?: boolean; +} diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..946cf12 --- /dev/null +++ b/client/src/main.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { MsalProvider } from '@azure/msal-react'; +import { msalInstance } from './lib/authConfig'; +import App from './App'; +import './styles.css'; + +msalInstance.initialize().then(() => { + ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + ); +}); diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx new file mode 100644 index 0000000..8f1a256 --- /dev/null +++ b/client/src/pages/Dashboard.tsx @@ -0,0 +1,116 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../lib/api'; +import type { Projekt, ProjektStatus } from '../lib/types'; + +function dd(s: string | null) { + if (!s) return '–'; + const p = s.split('T')[0]; + const [y, m, d] = p.split('-'); + return `${d}.${m}.${y}`; +} + +export default function Dashboard() { + const navigate = useNavigate(); + const [projekte, setProjekte] = useState(null); + const [filter, setFilter] = useState('Aktiv'); + const [search, setSearch] = useState(''); + const [showForm, setShowForm] = useState(false); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(''); + const [form, setForm] = useState({ titel: '', auftraggeber: '', datum: new Date().toISOString().slice(0, 10), monteur: '', notiz: '' }); + + useEffect(() => { load(); }, []); + + async function load() { + try { + setProjekte(await api.projekte.list()); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + } + + const gefiltert = useMemo(() => { + if (!projekte) return []; + let items = projekte; + if (filter !== 'Alle') items = items.filter(p => p.status === filter); + const q = search.trim().toLowerCase(); + if (q) items = items.filter(p => (p.titel + ' ' + (p.auftraggeber ?? '') + ' ' + (p.monteur ?? '')).toLowerCase().includes(q)); + return items; + }, [projekte, filter, search]); + + async function createProjekt() { + if (!form.titel.trim()) { setErr('Bitte Projekt/Baustelle eingeben.'); return; } + setBusy(true); + setErr(''); + try { + const projekt = await api.projekte.create(form); + setShowForm(false); + navigate(`/projekte/${projekt.id}`); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + setBusy(false); + } + + return ( +
+
+ setSearch(e.target.value)} /> +
+ {(['Aktiv', 'Abgeschlossen', 'Alle'] as const).map(f => ( + + ))} +
+
+ +
+ {projekte === null &&
} + {projekte !== null && gefiltert.length === 0 &&
Keine Projekte gefunden.
} + {gefiltert.map(p => ( +
navigate(`/projekte/${p.id}`)}> +
+
{p.titel || '(ohne Namen)'}
+
+ {p.auftraggeber && {p.auftraggeber}} + {p.datum && {dd(p.datum)}} + {p.monteur && {p.monteur}} +
+
+
{p.status}
+
+ ))} +
+ + {!showForm &&
setShowForm(true)}>+ Neues Projekt anlegen
} + + {showForm && ( +
+
Neues Projekt
+
+ + + + +