commit 13cef3c96dae04c9aa6701a184c1d3ec2fbd47a1 Author: Fidelis Huber Date: Fri Aug 21 17:24:38 2026 +0200 Initial scaffold: React/Vite/TS PWA + Express/Prisma/Postgres backend 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 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 0000000..cba0192 Binary files /dev/null and b/client/public/icons/apple-touch-icon.png differ diff --git a/client/public/icons/favicon-32.png b/client/public/icons/favicon-32.png new file mode 100644 index 0000000..38974cb Binary files /dev/null and b/client/public/icons/favicon-32.png differ diff --git a/client/public/icons/icon-192.png b/client/public/icons/icon-192.png new file mode 100644 index 0000000..35f5e4d Binary files /dev/null and b/client/public/icons/icon-192.png differ diff --git a/client/public/icons/icon-512.png b/client/public/icons/icon-512.png new file mode 100644 index 0000000..079669d Binary files /dev/null and b/client/public/icons/icon-512.png differ 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
+
+ + + + +