Initial scaffold: React/Vite/TS PWA + Express/Prisma/Postgres backend
Build and Deploy / version (push) Successful in 1s
Build and Deploy / build (push) Failing after 1s
Build and Deploy / deploy (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
Fidelis Huber
2026-08-21 17:24:38 +02:00
co-authored by Claude Sonnet 5
commit 13cef3c96d
39 changed files with 1798 additions and 0 deletions
+80
View File
@@ -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 = <gitops/version : major.minor>.<git rev-list --count HEAD>.
# 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 :<VERSION> — 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 }}"
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
client/dist/
server/dist/
.env
.env.local
*.log
.DS_Store
+29
View File
@@ -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"]
+96
View File
@@ -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 `:<version>` 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/<uuid>`) 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 <url>`) → `204`, Container laufen
(`db` + `web`).
- Push oder „Run workflow" auslösen → beide Jobs grün, `gitops/stack.yml` zeigt danach
`image: …:1.0.<N>` (kein `:latest` mehr), Commit `deploy: v1.0.<N> [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).
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1" />
<meta name="theme-color" content="#0a1622" />
<link rel="icon" href="/icons/favicon-32.png" sizes="32x32" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<title>BergVOLT Elektrotechnik Materialschein</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+26
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+29
View File
@@ -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 (
<>
<UnauthenticatedTemplate>
<Login />
</UnauthenticatedTemplate>
<AuthenticatedTemplate>
<BrowserRouter>
<Header />
<main>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/projekte/:id" element={<ProjektDetail />} />
<Route path="/kabelrechner" element={<Kabelrechner />} />
</Routes>
</main>
</BrowserRouter>
</AuthenticatedTemplate>
</>
);
}
+36
View File
@@ -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 (
<>
<header>
<div className="header-logo">
<div>
<div className="logo-name">Berg<span>VOLT</span></div>
<div className="logo-sub">Elektrotechnik</div>
</div>
</div>
<div className="hdr-user">
<div className="hdr-av">{initials}</div>
<div>
<div className="hdr-name">{displayName.split(' ')[0]}</div>
<div className="hdr-role">{account?.username}</div>
</div>
</div>
<button className="btn-logout" onClick={() => instance.logoutPopup()}>Abmelden</button>
</header>
<div className="tabs">
<Link to="/" className={'tab' + (!onKabelrechner ? ' active' : '')}>📋 MATERIALSCHEIN</Link>
<Link to="/kabelrechner" className={'tab' + (onKabelrechner ? ' active' : '')}> KABELRECHNER</Link>
</div>
</>
);
}
+28
View File
@@ -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 => (
<optgroup key={g.label} label={g.label}>
{KAB_DATA.filter(g.test).map(c => (
<option key={c.name} value={mode === 'combo' ? `${c.name}|${c.kgpm}` : String(c.kgpm)}>
{c.name}
</option>
))}
</optgroup>
))}
</>
);
}
+75
View File
@@ -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);
}
+36
View File
@@ -0,0 +1,36 @@
import { msalInstance } from './authConfig';
import type { MaterialItem, Projekt } from './types';
async function authHeader(): Promise<HeadersInit> {
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<T>(url: string, init?: RequestInit): Promise<T> {
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<T>;
}
export const api = {
projekte: {
list: () => req<Projekt[]>('/api/projekte'),
create: (data: Partial<Projekt>) => req<Projekt>('/api/projekte', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: Partial<Projekt>) => req<Projekt>(`/api/projekte/${id}`, { method: 'PATCH', body: JSON.stringify(data) })
},
material: {
list: (projektId: string) => req<MaterialItem[]>(`/api/material?projektId=${encodeURIComponent(projektId)}`),
create: (data: { projektId: string; bezeichnung: string; artNr?: string; menge: number; einheit: string; notiz?: string }) =>
req<MaterialItem>('/api/material', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: { menge?: number; notiz?: string }) =>
req<MaterialItem>(`/api/material/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
remove: (id: string) => req<void>(`/api/material/${id}`, { method: 'DELETE' })
}
};
+19
View File
@@ -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);
+26
View File
@@ -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;
}
+16
View File
@@ -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(
<React.StrictMode>
<MsalProvider instance={msalInstance}>
<App />
</MsalProvider>
</React.StrictMode>
);
});
+116
View File
@@ -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<Projekt[] | null>(null);
const [filter, setFilter] = useState<ProjektStatus | 'Alle'>('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 (
<div>
<div className="dash-toolbar">
<input className="dash-search" placeholder="Projekt suchen…" value={search} onChange={e => setSearch(e.target.value)} />
<div className="dash-filters">
{(['Aktiv', 'Abgeschlossen', 'Alle'] as const).map(f => (
<button key={f} className={'dfilter' + (filter === f ? ' active' : '')} onClick={() => setFilter(f)}>{f}</button>
))}
</div>
</div>
<div className="proj-list">
{projekte === null && <div className="empty-msg"><div className="spin" /></div>}
{projekte !== null && gefiltert.length === 0 && <div className="empty-msg">Keine Projekte gefunden.</div>}
{gefiltert.map(p => (
<div key={p.id} className="proj-card" onClick={() => navigate(`/projekte/${p.id}`)}>
<div className="proj-main">
<div className="proj-name">{p.titel || '(ohne Namen)'}</div>
<div className="proj-meta">
{p.auftraggeber && <span>{p.auftraggeber}</span>}
{p.datum && <span>{dd(p.datum)}</span>}
{p.monteur && <span>{p.monteur}</span>}
</div>
</div>
<div className={'proj-status ' + (p.status === 'Abgeschlossen' ? 'status-abgeschlossen' : 'status-aktiv')}>{p.status}</div>
</div>
))}
</div>
{!showForm && <div className="new-proj-card" style={{ marginTop: 14 }} onClick={() => setShowForm(true)}>+ Neues Projekt anlegen</div>}
{showForm && (
<div className="card" style={{ marginTop: 14 }}>
<div className="card-title">Neues Projekt</div>
<div className="grid-2">
<label className="field">Projekt / Baustelle
<input value={form.titel} onChange={e => setForm({ ...form, titel: e.target.value })} placeholder="z. B. EFH Müller, Dornbirn" />
</label>
<label className="field">Auftraggeber
<input value={form.auftraggeber} onChange={e => setForm({ ...form, auftraggeber: e.target.value })} placeholder="Kundenname" />
</label>
<label className="field">Datum
<input type="date" value={form.datum} onChange={e => setForm({ ...form, datum: e.target.value })} />
</label>
<label className="field">Monteur
<input value={form.monteur} onChange={e => setForm({ ...form, monteur: e.target.value })} placeholder="Name" />
</label>
<label className="field" style={{ gridColumn: 'span 2' }}>Notiz / Bereich
<textarea value={form.notiz} onChange={e => setForm({ ...form, notiz: e.target.value })} placeholder="Stockwerk, Bereich, Bemerkung …" />
</label>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 16 }}>
<button className="btn btn-cyan" disabled={busy} onClick={createProjekt}> Anlegen &amp; öffnen</button>
<button className="btn btn-ghost" onClick={() => setShowForm(false)}>Abbrechen</button>
</div>
</div>
)}
{err && <div className="lerr" style={{ marginTop: 12 }}>{err}</div>}
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useState } from 'react';
import { KAB_DATA } from '../data/kabelData';
import KabelOptions from '../components/KabelOptions';
export default function Kabelrechner() {
const [typSel, setTypSel] = useState('');
const [kgpm, setKgpm] = useState('');
const [kgpmReadOnly, setKgpmReadOnly] = useState(true);
const [gewicht, setGewicht] = useState('');
const [useTara, setUseTara] = useState(false);
const [tara, setTara] = useState('');
const [typName, setTypName] = useState('Kabel');
function onTypChange(v: string) {
setTypSel(v);
if (v === 'custom') { setKgpm(''); setKgpmReadOnly(false); setTypName('Eigenes kg/m'); }
else { setKgpm(v); setKgpmReadOnly(true); const opt = KAB_DATA.find(c => String(c.kgpm) === v); setTypName(opt?.name ?? 'Kabel'); }
}
const kgpmNum = parseFloat(kgpm);
const rawKg = parseFloat(gewicht);
const taraKg = useTara ? (parseFloat(tara) || 0) : 0;
const netto = rawKg - taraKg;
const visible = kgpmNum > 0 && rawKg > 0 && netto > 0;
const laenge = visible ? netto / kgpmNum : 0;
function pickFromRef(c: { kgpm: number; name: string }) {
setTypSel(String(c.kgpm));
setKgpm(String(c.kgpm));
setKgpmReadOnly(true);
setTypName(c.name);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
return (
<div>
<div className="card">
<div className="card-title">Gewicht Kabellänge (Meinhart Daten)</div>
<div className="grid-2">
<label className="field">Kabeltyp
<select value={typSel} onChange={e => onTypChange(e.target.value)}>
<option value="" disabled> bitte wählen </option>
<KabelOptions mode="kgpm" />
<optgroup label="Manuell"><option value="custom">Eigenes kg/m </option></optgroup>
</select>
</label>
<label className="field">kg pro Meter
<input type="number" min={0.001} step={0.001} placeholder="z. B. 0.170" value={kgpm} readOnly={kgpmReadOnly} onChange={e => setKgpm(e.target.value)} />
</label>
</div>
<div style={{ marginTop: 14 }}>
<label className="field" style={{ maxWidth: 280 }}>Gemessenes Gewicht (kg)
<input type="number" min={0} step={0.01} placeholder="z. B. 12.50" value={gewicht} onChange={e => setGewicht(e.target.value)} />
</label>
</div>
<div className="tare-row">
<label className="toggle"><input type="checkbox" checked={useTara} onChange={e => setUseTara(e.target.checked)} /><span className="slider" /></label>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--muted)' }}>Tara abziehen (Ring / Trommel)</span>
</div>
{useTara && (
<div className="grid-2">
<label className="field" style={{ marginTop: 12, maxWidth: 220 }}>Tara-Gewicht (kg)
<input type="number" min={0} step={0.01} placeholder="z. B. 1.20" value={tara} onChange={e => setTara(e.target.value)} />
</label>
</div>
)}
{visible && (
<div className="kc-result-box">
<div style={{ fontSize: 10, letterSpacing: '.08em', color: 'var(--muted)', textTransform: 'uppercase', marginBottom: 6 }}>Berechnete Länge</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<div className="kc-main">{laenge.toFixed(1)}</div>
<div style={{ fontSize: 18, opacity: .7 }}>m</div>
</div>
<div className="kc-detail">
Kabel: <strong style={{ color: 'var(--cyan)' }}>{typName}</strong><br />
Gewicht: <strong>{rawKg.toFixed(2)} kg</strong>
{useTara && taraKg > 0 ? <> Tara <strong>{taraKg.toFixed(2)} kg</strong> = Netto <strong>{netto.toFixed(2)} kg</strong></> : null}
<br />Faktor: <strong>{kgpmNum.toFixed(3)} kg/m</strong>
</div>
</div>
)}
</div>
<div className="card">
<div className="card-title">Referenztabelle Meinhart Kabel Österreich GmbH</div>
<div className="ref-wrap">
<table className="reftab">
<thead><tr><th>Typ</th><th>Bezeichnung</th><th>kg/m</th><th>bei 100 kg</th></tr></thead>
<tbody>
{KAB_DATA.map(c => (
<tr key={c.name} title={`Klick: ${c.name} übernehmen`} onClick={() => pickFromRef(c)}>
<td><span className={`badge badge-${c.badge}`}>{c.typ}</span></td>
<td>{c.name}</td>
<td><strong style={{ color: 'var(--cyan)' }}>{c.kgpm.toFixed(3)}</strong></td>
<td>{(100 / c.kgpm).toFixed(1)} m</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useState } from 'react';
import { useMsal } from '@azure/msal-react';
export default function Login() {
const { instance } = useMsal();
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
async function login() {
setBusy(true);
setErr('');
try {
await instance.loginPopup({ scopes: ['openid', 'profile'] });
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
}
setBusy(false);
}
return (
<div className="login-screen">
<div className="lcard">
<div className="llogo">Berg<span>VOLT</span></div>
<div className="lsub">Elektrotechnik · Materialschein</div>
<div className="ltitle">Willkommen</div>
<div className="ldesc">Melde dich mit deinem BergVOLT<br />Office 365 Konto an.</div>
<button className="btn-ms" onClick={login} disabled={busy}>
<svg viewBox="0 0 21 21" width="18" height="18">
<rect x="0" y="0" width="10" height="10" fill="#f25022" />
<rect x="11" y="0" width="10" height="10" fill="#7fba00" />
<rect x="0" y="11" width="10" height="10" fill="#00a4ef" />
<rect x="11" y="11" width="10" height="10" fill="#ffb900" />
</svg>
Mit Microsoft anmelden
</button>
{err && <div className="lerr">{err}</div>}
</div>
</div>
);
}
+390
View File
@@ -0,0 +1,390 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../lib/api';
import type { MaterialItem, Projekt } from '../lib/types';
import { kabelInfoForBez } from '../data/kabelData';
import KabelOptions from '../components/KabelOptions';
const fmt1 = (v: number | string) => (Math.round((parseFloat(String(v)) || 0) * 10) / 10).toFixed(1);
const num1 = (v: number | string) => Math.round((parseFloat(String(v)) || 0) * 10) / 10;
export default function ProjektDetail() {
const { id } = useParams<{ id: string }>();
const [projekt, setProjekt] = useState<Projekt | null>(null);
const [matList, setMatList] = useState<MaterialItem[]>([]);
const [loading, setLoading] = useState(true);
// Baustelle-Formular
const [titel, setTitel] = useState('');
const [auftraggeber, setAuftraggeber] = useState('');
const [datum, setDatum] = useState('');
const [monteur, setMonteur] = useState('');
const [notiz, setNotiz] = useState('');
// Kabel inline
const [kabTyp, setKabTyp] = useState('');
const [kabKg, setKabKg] = useState('');
const [kabTara, setKabTara] = useState('');
const [kabLaenge, setKabLaenge] = useState('');
const [kabBusy, setKabBusy] = useState(false);
// Manuell
const [mBez, setMBez] = useState('');
const [mArt, setMArt] = useState('');
const [mQty, setMQty] = useState('1');
const [mEinh, setMEinh] = useState('Stk');
const [mBusy, setMBusy] = useState(false);
// Retour
const [retourMode, setRetourMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [retourQty, setRetourQty] = useState('');
const [retourKg, setRetourKg] = useState('');
const [retourBusy, setRetourBusy] = useState(false);
useEffect(() => { if (id) load(id); }, [id]);
async function load(projektId: string) {
setLoading(true);
const [p, list] = await Promise.all([
api.projekte.list().then(all => all.find(x => x.id === projektId) ?? null),
api.material.list(projektId)
]);
setProjekt(p);
setMatList(list);
if (p) {
setTitel(p.titel); setAuftraggeber(p.auftraggeber ?? ''); setDatum(p.datum ? p.datum.slice(0, 10) : '');
setMonteur(p.monteur ?? ''); setNotiz(p.notiz ?? '');
}
setLoading(false);
}
async function reloadMaterial() {
if (id) setMatList(await api.material.list(id));
}
async function saveFeld(feld: 'titel' | 'auftraggeber' | 'datum' | 'monteur' | 'notiz', value: string) {
if (!id) return;
await api.projekte.update(id, { [feld]: value } as Partial<Projekt>);
}
async function toggleStatus() {
if (!id || !projekt) return;
const neu = projekt.status === 'Abgeschlossen' ? 'Aktiv' : 'Abgeschlossen';
const updated = await api.projekte.update(id, { status: neu });
setProjekt(updated);
}
// ── Kabel-Rechner ──
const kabpm = kabTyp ? parseFloat(kabTyp.split('|')[1]) : 0;
const kabNetto = (parseFloat(kabKg) || 0) - (parseFloat(kabTara) || 0);
const kabResultVisible = !!kabTyp && !!kabKg && parseFloat(kabKg) > 0 && kabNetto > 0;
const kabResultLaenge = kabResultVisible ? kabNetto / kabpm : 0;
useEffect(() => {
if (kabResultVisible) setKabLaenge(kabResultLaenge.toFixed(1));
}, [kabTyp, kabKg, kabTara]); // eslint-disable-line react-hooks/exhaustive-deps
async function addKabelToList() {
if (!id) return;
if (!kabTyp) { alert('Bitte Kabeltyp wählen.'); return; }
const laenge = parseFloat(kabLaenge);
if (!laenge || laenge <= 0) { alert('Bitte Gewicht eingeben oder Länge direkt eintragen.'); return; }
const typName = kabTyp.split('|')[0];
const kg = parseFloat(kabKg) || 0;
setKabBusy(true);
try {
await api.material.create({
projektId: id, bezeichnung: 'Kabel ' + typName, artNr: '', menge: num1(laenge), einheit: 'm',
notiz: kg > 0 ? `${kg.toFixed(2)} kg gewogen` : ''
});
setKabKg(''); setKabLaenge('');
await reloadMaterial();
} catch (e) { alert('Fehler beim Speichern: ' + (e instanceof Error ? e.message : e)); }
setKabBusy(false);
}
// ── Manuell ──
async function addManual() {
if (!id) return;
if (!mBez.trim()) { alert('Bitte Bezeichnung eingeben.'); return; }
setMBusy(true);
try {
await api.material.create({ projektId: id, bezeichnung: mBez.trim(), artNr: mArt.trim(), menge: num1(mQty) || 1, einheit: mEinh });
setMBez(''); setMArt(''); setMQty('1');
await reloadMaterial();
} catch (e) { alert('Fehler beim Speichern: ' + (e instanceof Error ? e.message : e)); }
setMBusy(false);
}
// ── Zeilen-Aktionen ──
async function updateMenge(row: MaterialItem, val: string) {
const neu = Math.max(0, num1(val)) || 0;
setMatList(prev => prev.map(r => r.id === row.id ? { ...r, menge: String(neu) } : r));
try { await api.material.update(row.id, { menge: neu }); }
catch (e) { alert('Fehler beim Speichern: ' + (e instanceof Error ? e.message : e)); await reloadMaterial(); }
}
async function updateNotiz(row: MaterialItem, val: string) {
setMatList(prev => prev.map(r => r.id === row.id ? { ...r, notiz: val } : r));
try { await api.material.update(row.id, { notiz: val }); }
catch (e) { alert('Fehler beim Speichern: ' + (e instanceof Error ? e.message : e)); await reloadMaterial(); }
}
async function deleteRow(row: MaterialItem) {
try { await api.material.remove(row.id); await reloadMaterial(); }
catch (e) { alert('Fehler beim Löschen: ' + (e instanceof Error ? e.message : e)); }
}
async function clearList() {
if (!matList.length || !confirm('Alle Positionen dieses Projekts löschen?')) return;
try { await Promise.all(matList.map(r => api.material.remove(r.id))); await reloadMaterial(); }
catch (e) { alert('Fehler beim Löschen: ' + (e instanceof Error ? e.message : e)); await reloadMaterial(); }
}
// ── Retour buchen ──
function toggleRetourMode() {
setRetourMode(v => !v);
setSelected(new Set());
}
function toggleRowSelect(rowId: string, checked: boolean) {
setSelected(prev => {
const next = new Set(prev);
if (checked) next.add(rowId); else next.delete(rowId);
return next;
});
}
const retourInfo = useMemo(() => {
const sel = matList.filter(r => selected.has(r.id));
if (!sel.length) return { mode: null as null | 'kabel' | 'material', text: 'Positionen in der Liste ankreuzen, die zusammengeführt werden sollen.', kgpm: 0, einh: '', summ: 0, error: false };
const kabelInfos = sel.map(r => kabelInfoForBez(r.bezeichnung));
const allKabel = kabelInfos.every(Boolean);
const anyKabel = kabelInfos.some(Boolean);
if (allKabel) {
const names = [...new Set(kabelInfos.map(k => k!.name))];
if (names.length > 1) return { mode: null, text: `${sel.length} Positionen ausgewählt unterschiedliche Kabeltypen. Bitte nur denselben Typ auswählen.`, kgpm: 0, einh: '', summ: 0, error: true };
const sumM = sel.reduce((s, r) => s + parseFloat(r.menge), 0);
return { mode: 'kabel' as const, text: `${sel.length} Position(en) · ${kabelInfos[0]!.name} · Summe ${fmt1(sumM)} m`, kgpm: kabelInfos[0]!.kgpm, einh: 'm', summ: sumM, error: false };
}
if (anyKabel) return { mode: null, text: `${sel.length} Positionen ausgewählt Kabel und normales Material lassen sich nicht gemeinsam zusammenführen.`, kgpm: 0, einh: '', summ: 0, error: true };
const einhSet = [...new Set(sel.map(r => r.einheit))];
if (einhSet.length > 1) return { mode: null, text: `${sel.length} Positionen ausgewählt unterschiedliche Einheiten (${einhSet.join(', ')}). Bitte nur gleiche Einheit auswählen.`, kgpm: 0, einh: '', summ: 0, error: true };
const sumQ = sel.reduce((s, r) => s + parseFloat(r.menge), 0);
return { mode: 'material' as const, text: `${sel.length} Position(en) ausgewählt · Summe ${fmt1(sumQ)} ${einhSet[0]}`, kgpm: 0, einh: einhSet[0], summ: sumQ, error: false };
}, [matList, selected]);
async function applyRetour() {
const sel = matList.filter(r => selected.has(r.id));
if (!sel.length || !retourInfo.mode || !id) return;
setRetourBusy(true);
const art = sel[0].artNr ?? '';
const bez = sel[0].bezeichnung;
let neueMenge: number, einh: string, notiz: string;
if (retourInfo.mode === 'kabel') {
const rKg = parseFloat(retourKg) || 0;
const rM = rKg / retourInfo.kgpm;
neueMenge = Math.max(0, num1(retourInfo.summ - rM));
einh = 'm';
notiz = rKg > 0 ? `Retour: ${rKg.toFixed(2)} kg (≈${fmt1(rM)} m) aus ${sel.length} Positionen` : `${sel.length} Positionen zusammengeführt`;
} else {
const r = num1(retourQty) || 0;
neueMenge = Math.max(0, num1(retourInfo.summ - r));
einh = retourInfo.einh;
notiz = r > 0 ? `Retour: ${fmt1(r)} ${einh} aus ${sel.length} Positionen` : `${sel.length} Positionen zusammengeführt`;
}
try {
await api.material.create({ projektId: id, bezeichnung: bez, artNr: art, menge: neueMenge, einheit: einh, notiz });
await Promise.all(sel.map(r => api.material.remove(r.id)));
setRetourMode(false); setSelected(new Set()); setRetourQty(''); setRetourKg('');
await reloadMaterial();
} catch (e) { alert('Fehler beim Zusammenführen: ' + (e instanceof Error ? e.message : e)); }
setRetourBusy(false);
}
if (loading) return <div className="empty-msg"><div className="spin" /></div>;
if (!projekt) return <div className="empty-msg">Projekt nicht gefunden.</div>;
return (
<div>
<div className="print-header">
<h2 style={{ fontSize: 18, marginBottom: 4 }}>Materialschein BergVOLT Elektrotechnik {titel}</h2>
<p style={{ fontSize: 12, color: '#555' }}>Datum: {datum || '—'} | Monteur: {monteur || '—'} | Auftraggeber: {auftraggeber || '—'}</p>
<hr style={{ margin: '8px 0', borderColor: '#ddd' }} />
</div>
<div className="detail-back">
<Link className="btn-back" to="/"> Projekte</Link>
<div className="detail-title">{titel || '(ohne Namen)'}</div>
<button className="btn btn-outline" style={{ height: 36, fontSize: 11, padding: '0 14px' }} onClick={toggleStatus}>
{projekt.status === 'Abgeschlossen' ? '↺ Wieder aktivieren' : '✓ Abschließen'}
</button>
</div>
<div className="card">
<div className="card-title">Baustelle / Projekt</div>
<div className="grid-2">
<label className="field">Projekt / Baustelle
<input value={titel} onChange={e => setTitel(e.target.value)} onBlur={e => saveFeld('titel', e.target.value)} />
</label>
<label className="field">Auftraggeber
<input value={auftraggeber} onChange={e => setAuftraggeber(e.target.value)} onBlur={e => saveFeld('auftraggeber', e.target.value)} />
</label>
<label className="field">Datum
<input type="date" value={datum} onChange={e => setDatum(e.target.value)} onBlur={e => saveFeld('datum', e.target.value)} />
</label>
<label className="field">Monteur
<input value={monteur} onChange={e => setMonteur(e.target.value)} onBlur={e => saveFeld('monteur', e.target.value)} />
</label>
<label className="field" style={{ gridColumn: 'span 2' }}>Notiz / Bereich
<textarea value={notiz} onChange={e => setNotiz(e.target.value)} onBlur={e => saveFeld('notiz', e.target.value)} />
</label>
</div>
</div>
<div className="card kab-card">
<div className="card-title">Kabel hinzufügen Gewicht direkt eingeben</div>
<div className="kab-inline">
<label className="field">Kabeltyp (Meinhart)
<select value={kabTyp} onChange={e => setKabTyp(e.target.value)}>
<option value="" disabled> wählen </option>
<KabelOptions />
</select>
</label>
<label className="field">Gewicht gesamt (kg)
<input type="number" min={0} step={0.01} placeholder="z. B. 12.50" value={kabKg} onChange={e => setKabKg(e.target.value)} />
</label>
<label className="field">Tara kg (optional)
<input type="number" min={0} step={0.01} placeholder="0.00" value={kabTara} onChange={e => setKabTara(e.target.value)} />
</label>
<label className="field">Länge (m)
<input type="number" min={0} step={0.1} placeholder="oder direkt" value={kabLaenge} onChange={e => { setKabLaenge(e.target.value); setKabKg(''); }} />
</label>
<label className="field"><span style={{ opacity: 0 }}>.</span>
<button className="btn btn-cyan" style={{ width: '100%' }} disabled={kabBusy} onClick={addKabelToList}>+ Liste</button>
</label>
</div>
{kabResultVisible && (
<div className="kab-result-inline">
<div>
<div style={{ fontSize: 10, letterSpacing: '.07em', color: 'var(--muted)', textTransform: 'uppercase', marginBottom: 4 }}>Berechnete Länge</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<div className="kab-res-big">{kabResultLaenge.toFixed(1)}</div>
<div style={{ fontSize: 14, opacity: .7 }}>m</div>
</div>
</div>
<div className="kab-res-info">
{kabTyp.split('|')[0]} · {kabpm.toFixed(3)} kg/m<br />
{(parseFloat(kabKg) || 0).toFixed(2)} kg{(parseFloat(kabTara) || 0) > 0 ? ` ${(parseFloat(kabTara) || 0).toFixed(2)} kg Tara = ${kabNetto.toFixed(2)} kg netto` : ''}
</div>
</div>
)}
</div>
<div className="card manual-card">
<div className="card-title">Material eintragen</div>
<div className="grid-4">
<label className="field">Bezeichnung
<input value={mBez} onChange={e => setMBez(e.target.value)} placeholder="z. B. LS B16 1+N, Kabelkanal 60×40 …" />
</label>
<label className="field">Art.-Nr.
<input value={mArt} onChange={e => setMArt(e.target.value)} placeholder="optional" />
</label>
<label className="field">Menge
<input type="number" inputMode="decimal" min={0.1} step={0.1} value={mQty} onChange={e => setMQty(e.target.value)} />
</label>
<label className="field">Einheit
<select value={mEinh} onChange={e => setMEinh(e.target.value)}>
<option>Stk</option><option>m</option><option>m²</option><option>Pkg</option><option>Rol</option><option>Set</option>
</select>
</label>
<label className="field"><span style={{ opacity: 0 }}>.</span>
<button className="btn btn-cyan" style={{ width: '100%' }} disabled={mBusy} onClick={addManual}>+ Hinzufügen</button>
</label>
</div>
</div>
<div className="card">
<div className="card-title" style={{ justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<span>Materialliste</span>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-ghost" style={{ height: 32, fontSize: 11, padding: '0 14px' }} onClick={reloadMaterial}>🔄 Aktualisieren</button>
<button className={'btn btn-outline btn-retour-toggle' + (retourMode ? ' active' : '')} style={{ height: 32, fontSize: 11, padding: '0 14px' }} onClick={toggleRetourMode}> Retour buchen</button>
<button className="btn btn-danger" style={{ height: 32, fontSize: 11, padding: '0 14px' }} onClick={clearList}>🗑 Leeren</button>
</div>
</div>
<div className="card-hint">Wurde dieselbe Position mehrmals geholt? Mit Retour buchen" die Positionen ankreuzen, Rückgabemenge (bzw. -gewicht bei Kabel) eingeben sie werden zu einer Position mit dem tatsächlichen Verbrauch zusammengeführt. Die Liste ist für alle Monteure im Team sichtbar.</div>
{matList.length === 0 && (
<div className="empty-msg">Noch keine Artikel.<br /><span style={{ fontSize: 12, opacity: .6 }}>Kabel berechnen oder Material manuell eintragen.</span></div>
)}
{matList.length > 0 && (
<div className={'table-wrap' + (retourMode ? ' retour-mode' : '')}>
<table className="mat-table">
<thead>
<tr>
<th className="chk-col"></th>
<th style={{ width: 32 }}>Pos</th>
<th style={{ width: 70 }}>Menge</th>
<th style={{ width: 48 }}>Einh.</th>
<th style={{ width: 90 }}>Art.-Nr.</th>
<th>Bezeichnung</th>
<th>Notiz</th>
<th style={{ width: 30 }}></th>
</tr>
</thead>
<tbody>
{matList.map((r, i) => (
<tr key={r.id} className={selected.has(r.id) ? 'row-selected' : undefined}>
<td className="chk-col">
<input type="checkbox" className="row-check" checked={selected.has(r.id)} onChange={e => toggleRowSelect(r.id, e.target.checked)} />
</td>
<td className="pos-nr">{i + 1}</td>
<td>
<input key={r.menge} className="qty-input" type="number" inputMode="decimal" min={0} step={0.1} defaultValue={r.menge} onBlur={e => updateMenge(r, e.target.value)} />
<span className="print-only">{fmt1(r.menge)}</span>
</td>
<td>{r.einheit}</td>
<td className="art-cell">{r.artNr || '—'}</td>
<td>{r.bezeichnung}</td>
<td>
<input key={r.notiz ?? ''} className="notiz-input" type="text" placeholder="" defaultValue={r.notiz ?? ''} onBlur={e => updateNotiz(r, e.target.value)} />
<span className="print-only">{r.notiz}</span>
</td>
<td><button className="del-btn" onClick={() => deleteRow(r)}>✕</button></td>
</tr>
))}
</tbody>
</table>
</div>
)}
{retourMode && (
<div className="retour-panel">
<div className="retour-panel-info" style={retourInfo.error ? { color: '#ff5555', fontWeight: 700 } : undefined}>{retourInfo.text}</div>
{retourInfo.mode === 'material' && (
<label className="field" style={{ minWidth: 150 }}>Retourmenge
<input type="number" min={0} step={0.1} placeholder="0" value={retourQty} onChange={e => setRetourQty(e.target.value)} />
</label>
)}
{retourInfo.mode === 'kabel' && (
<label className="field" style={{ minWidth: 150 }}>Retourgewicht (kg)
<input type="number" min={0} step={0.01} placeholder="0.00" value={retourKg} onChange={e => setRetourKg(e.target.value)} />
</label>
)}
<button className="btn btn-cyan" disabled={!retourInfo.mode || retourBusy} onClick={applyRetour}>✓ Zusammenführen</button>
<button className="btn btn-ghost" onClick={() => { setRetourMode(false); setSelected(new Set()); }}>Abbrechen</button>
</div>
)}
{matList.length > 0 && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--bor)', fontSize: 11, color: 'var(--muted)' }}>
{matList.length} Position(en) eingetragen
</div>
)}
</div>
<div className="print-bar">
<button className="btn btn-cyan" onClick={() => window.print()}>🖨 DRUCKEN / PDF</button>
</div>
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
:root{
--bg:#0a1622;
--bg2:#0f2033;
--card:#122a42;
--cyan:#00c8d8;
--text:#eef7fa;
--muted:#87abbc;
--bor:#22475f;
--r:14px;
--sh:0 4px 24px rgba(0,0,0,.35);
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:var(--bg);color:var(--text);min-height:100vh;font-size:14px;line-height:1.4}
button,input,select,textarea{font-family:inherit}
/* Login */
.login-screen{display:flex;align-items:center;justify-content:center;min-height:100vh;background:#070f18;position:relative;overflow:hidden;padding:20px}
.login-screen::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 70% 60% at 30% 30%,rgba(0,200,216,.08) 0%,transparent 70%)}
.lcard{background:var(--card);border:1px solid var(--bor);border-radius:var(--r);box-shadow:var(--sh);padding:44px 36px;width:380px;max-width:100%;text-align:center;position:relative;z-index:1}
.llogo{font-size:34px;font-weight:900;letter-spacing:1px;color:#fff;text-transform:uppercase;display:flex;align-items:center;justify-content:center;gap:2px}
.llogo span{color:var(--cyan)}
.lsub{font-size:10px;letter-spacing:5px;text-transform:uppercase;color:var(--cyan);opacity:.8;margin-top:6px;margin-bottom:26px}
.ltitle{font-size:16px;font-weight:700;color:#fff;margin-bottom:8px}
.ldesc{color:var(--muted);font-size:13px;margin-bottom:26px;line-height:1.6}
.btn-ms{display:flex;align-items:center;justify-content:center;gap:10px;background:#0078d4;color:#fff;border:none;border-radius:9px;padding:14px;width:100%;font-size:14px;font-weight:700;cursor:pointer}
.btn-ms:hover{background:#1084d8}
.btn-ms:disabled{opacity:.5;cursor:not-allowed}
.lerr{margin-top:14px;color:#ff6b6b;font-size:12.5px;line-height:1.5}
/* Header */
header{background:linear-gradient(135deg,#081420 0%,#0c1c30 100%);border-bottom:1px solid var(--bor);padding:0 20px;display:flex;align-items:center;gap:16px;position:sticky;top:0;z-index:100;box-shadow:0 2px 20px rgba(0,0,0,.4)}
.header-logo{display:flex;align-items:center;gap:14px;padding:14px 0;flex:1;min-width:0}
.logo-name{font-size:22px;font-weight:900;letter-spacing:2px;color:#fff;text-transform:uppercase}
.logo-name span{color:var(--cyan)}
.logo-sub{font-size:10px;letter-spacing:6px;color:var(--cyan);text-transform:uppercase;opacity:.85}
.hdr-user{display:flex;align-items:center;gap:9px;flex-shrink:0}
.hdr-av{width:32px;height:32px;border-radius:8px;background:rgba(0,200,216,.12);border:1px solid rgba(0,200,216,.3);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;color:var(--cyan);flex-shrink:0}
.hdr-name{font-size:12px;font-weight:700;color:#fff;line-height:1.3}
.hdr-role{font-size:9.5px;color:var(--muted)}
.btn-logout{background:rgba(255,255,255,.06);border:1px solid var(--bor);color:var(--muted);border-radius:8px;padding:8px 12px;font-size:11px;font-weight:700;cursor:pointer;flex-shrink:0}
.btn-logout:hover{color:#ff6b6b;border-color:rgba(255,85,85,.4)}
/* Tabs */
.tabs{background:#081420;display:flex;padding:0 20px;gap:2px;border-bottom:1px solid var(--bor);overflow-x:auto}
.tab{padding:13px 20px;font-size:12px;font-weight:700;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;letter-spacing:.05em;text-transform:uppercase;white-space:nowrap;text-decoration:none;display:inline-block}
.tab.active{color:var(--cyan);border-bottom-color:var(--cyan)}
.tab:hover{color:var(--text)}
main{max-width:920px;margin:0 auto;padding:22px 18px 100px}
.card{background:var(--card);border:1px solid var(--bor);border-radius:var(--r);box-shadow:var(--sh);padding:22px;margin-bottom:18px}
.card-title{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--cyan);margin-bottom:16px;display:flex;align-items:center;gap:8px}
.card-title::before{content:'';display:block;width:3px;height:12px;background:var(--cyan);border-radius:2px;box-shadow:0 0 8px var(--cyan)}
.card-hint{font-size:11.5px;color:var(--muted);margin:-8px 0 16px;line-height:1.6}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.grid-4{display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:12px;align-items:end}
.grid-2>*,.grid-4>*{min-width:0}
@media(max-width:620px){.grid-2,.grid-4{grid-template-columns:1fr}}
label.field{display:flex;flex-direction:column;gap:6px;font-size:11px;font-weight:700;color:var(--muted);letter-spacing:.05em;text-transform:uppercase}
label.field input,label.field select,label.field textarea{height:44px;border:1.5px solid var(--bor);border-radius:9px;padding:0 12px;font-size:14px;background:var(--bg2);color:var(--text)}
label.field textarea{height:60px;padding:9px 12px;resize:vertical}
label.field input:focus,label.field select:focus,label.field textarea:focus{outline:none;border-color:var(--cyan);box-shadow:0 0 0 3px rgba(0,200,216,.15)}
label.field select option{background:#0c1a2b}
.kab-inline{display:grid;grid-template-columns:1.6fr 1fr 1fr 1fr auto;gap:12px;align-items:end}
.kab-inline>*{min-width:0}
@media(max-width:720px){.kab-inline{grid-template-columns:1fr 1fr}}
@media(max-width:480px){.kab-inline{grid-template-columns:1fr}}
.kab-result-inline{margin-top:14px;padding:16px 18px;background:rgba(0,200,216,.08);border:1px solid rgba(0,200,216,.2);border-radius:10px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px}
.kab-res-big{font-size:32px;font-weight:900;color:var(--cyan);line-height:1;letter-spacing:-1px}
.kab-res-info{font-size:11px;color:var(--muted);line-height:1.6}
.table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border-radius:10px;border:1px solid var(--bor)}
.mat-table{width:100%;min-width:720px;border-collapse:collapse;font-size:12.5px}
.mat-table th{background:rgba(0,200,216,.12);color:var(--cyan);padding:10px 10px;text-align:left;font-size:10px;letter-spacing:.07em;text-transform:uppercase;font-weight:700;border-bottom:1px solid var(--bor);white-space:nowrap}
.mat-table td{padding:8px 10px;border-bottom:1px solid rgba(34,71,95,.5);color:var(--text)}
.mat-table tr:last-child td{border-bottom:none}
.mat-table tr:hover td{background:rgba(0,200,216,.05)}
.qty-input{width:64px;height:34px;border:1px solid var(--bor);border-radius:7px;padding:0 6px;font-size:13px;text-align:center;background:var(--bg2);color:var(--text)}
.notiz-input{width:100%;min-width:120px;height:32px;border:1px solid var(--bor);border-radius:7px;padding:0 8px;font-size:12px;background:var(--bg2);color:var(--text)}
.del-btn{background:none;border:none;color:#ff5555;font-size:19px;cursor:pointer;padding:6px 8px;opacity:.75}
.del-btn:hover{opacity:1}
.pos-nr{font-size:10px;color:var(--muted);text-align:center}
.art-cell{font-family:monospace;font-size:10px;color:var(--muted)}
.empty-msg{text-align:center;color:var(--muted);padding:40px 20px;font-size:13px;line-height:1.8}
.chk-col{display:none;width:36px;text-align:center}
.table-wrap.retour-mode .chk-col{display:table-cell}
.row-check{width:19px;height:19px;cursor:pointer;accent-color:var(--cyan)}
tr.row-selected td{background:rgba(255,176,96,.09)}
.retour-panel{margin-top:14px;padding:16px 18px;background:rgba(255,176,96,.08);border:1px solid rgba(255,176,96,.3);border-radius:10px;display:flex;flex-wrap:wrap;align-items:end;gap:14px}
.retour-panel-info{flex:1 1 220px;font-size:12.5px;color:var(--text);line-height:1.6}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:44px;padding:0 18px;border-radius:9px;border:none;font-size:12.5px;font-weight:700;cursor:pointer;letter-spacing:.04em;text-decoration:none}
.btn:active{transform:scale(.97)}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn-cyan{background:var(--cyan);color:#07111e}
.btn-cyan:hover{background:#00e0f0}
.btn-outline{background:transparent;color:var(--cyan);border:1.5px solid var(--cyan)}
.btn-outline:hover{background:rgba(0,200,216,.1)}
.btn-outline.active{background:var(--cyan);color:#07111e}
.btn-ghost{background:rgba(255,255,255,.06);color:var(--text);border:1px solid var(--bor)}
.btn-ghost:hover{background:rgba(255,255,255,.1)}
.btn-danger{background:transparent;color:#ff5555;border:1px solid rgba(255,85,85,.35)}
.kc-result-box{margin-top:16px;background:rgba(0,200,216,.08);border:1px solid rgba(0,200,216,.2);border-radius:12px;padding:20px}
.kc-main{font-size:44px;font-weight:900;color:var(--cyan);letter-spacing:-2px;line-height:1}
.kc-detail{margin-top:10px;padding-top:10px;border-top:1px solid rgba(0,200,216,.15);font-size:12px;color:var(--muted);line-height:1.7}
.tare-row{display:flex;align-items:center;gap:10px;margin-top:14px}
.toggle{position:relative;width:42px;height:24px;flex-shrink:0}
.toggle input{opacity:0;width:0;height:0}
.slider{position:absolute;inset:0;background:var(--bor);border-radius:24px;cursor:pointer;transition:background .2s}
.slider::before{content:'';position:absolute;width:18px;height:18px;left:3px;top:3px;background:#fff;border-radius:50%;transition:transform .2s}
.toggle input:checked + .slider{background:var(--cyan)}
.toggle input:checked + .slider::before{transform:translateX(18px)}
.ref-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
table.reftab{width:100%;min-width:480px;border-collapse:collapse;font-size:12px}
table.reftab th{background:rgba(0,200,216,.1);color:var(--cyan);padding:9px 12px;text-align:left;font-size:10px;letter-spacing:.07em;text-transform:uppercase;border-bottom:1px solid var(--bor)}
table.reftab td{padding:8px 12px;border-bottom:1px solid rgba(34,71,95,.5);color:var(--text)}
table.reftab tr:hover td{background:rgba(0,200,216,.06);cursor:pointer}
.badge{display:inline-block;padding:1px 7px;border-radius:8px;font-size:10px;font-weight:700}
.badge-nym{background:rgba(0,150,255,.2);color:#60c8ff}
.badge-nyy{background:rgba(255,150,0,.2);color:#ffb060}
.badge-h07{background:rgba(140,0,200,.2);color:#d07aff}
.badge-ysly{background:rgba(255,70,70,.2);color:#ff8080}
.badge-schlauch{background:rgba(60,200,130,.2);color:#60e0a0}
/* Dashboard */
.dash-toolbar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:16px}
.dash-search{flex:1;min-width:180px;height:44px;border:1.5px solid var(--bor);border-radius:9px;padding:0 14px;font-size:14px;background:var(--bg2);color:var(--text)}
.dash-search:focus{outline:none;border-color:var(--cyan)}
.dash-filters{display:flex;gap:6px}
.dfilter{padding:7px 14px;border-radius:20px;border:1px solid var(--bor);background:transparent;font-size:11px;font-weight:700;color:var(--muted);cursor:pointer;white-space:nowrap}
.dfilter.active{background:var(--cyan);border-color:var(--cyan);color:#07111e}
.proj-list{display:flex;flex-direction:column;gap:10px}
.proj-card{background:var(--card);border:1px solid var(--bor);border-radius:12px;padding:16px 18px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;text-decoration:none;color:inherit}
.proj-card:hover{border-color:var(--cyan)}
.proj-name{font-size:15px;font-weight:800;color:#fff;margin-bottom:4px;word-break:break-word}
.proj-meta{font-size:11.5px;color:var(--muted);display:flex;gap:12px;flex-wrap:wrap}
.proj-status{font-size:10px;font-weight:800;padding:3px 10px;border-radius:20px;text-transform:uppercase;letter-spacing:.05em;flex-shrink:0}
.status-aktiv{background:rgba(0,230,118,.15);color:#50ffaa}
.status-abgeschlossen{background:rgba(135,171,188,.15);color:var(--muted)}
.new-proj-card{background:transparent;border:1.5px dashed var(--bor);border-radius:12px;padding:16px 18px;text-align:center;cursor:pointer;color:var(--muted);font-weight:700;font-size:13px}
.new-proj-card:hover{border-color:var(--cyan);color:var(--cyan)}
.detail-back{display:flex;align-items:center;gap:10px;margin-bottom:16px;flex-wrap:wrap}
.btn-back{background:rgba(255,255,255,.06);border:1px solid var(--bor);color:var(--text);border-radius:9px;padding:9px 14px;font-size:12px;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:6px;text-decoration:none}
.btn-back:hover{border-color:var(--cyan);color:var(--cyan)}
.detail-title{font-size:17px;font-weight:800;color:#fff;flex:1;min-width:150px}
.spin{width:22px;height:22px;border:2px solid var(--bor);border-top-color:var(--cyan);border-radius:50%;animation:sp .8s linear infinite;margin:0 auto}
@keyframes sp{to{transform:rotate(360deg)}}
.print-bar{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(90deg,#081420,#0c1c30);border-top:1px solid var(--bor);padding:14px 20px;display:flex;gap:10px;justify-content:center;box-shadow:0 -2px 20px rgba(0,0,0,.4);z-index:99}
.print-header{display:none}
@media print{
header,.tabs,.print-bar,.detail-back,.kab-card,.manual-card,.del-btn,.qty-input,.notiz-input,
.chk-col,.retour-panel,.btn-retour-toggle{display:none!important}
body{background:#fff;color:#000}
.card{box-shadow:none;border:1px solid #ccc;background:#fff;page-break-inside:avoid}
.print-header{display:block!important;margin-bottom:12px}
.table-wrap{border:none;overflow:visible}
.mat-table{min-width:0}
.mat-table th{background:#f0f0f0;color:#000}
.mat-table td{color:#000;border-bottom:1px solid #ddd}
.art-cell,.pos-nr{color:#555}
.print-only{display:inline}
}
.print-only{display:none}
@media(max-width:768px){
body{font-size:15px}
main{padding:16px 12px 110px}
.card{padding:16px;border-radius:12px;margin-bottom:14px}
label.field input,label.field select,label.field textarea{font-size:16px;height:46px}
.mat-table th,.mat-table td{padding:9px 8px;font-size:12px}
.qty-input{width:60px;height:38px;font-size:14px}
.btn{height:46px;font-size:13px}
.logo-name{font-size:18px;letter-spacing:1px}
.logo-sub{font-size:8.5px;letter-spacing:3.5px}
header{padding:0 14px;gap:10px}
.tabs{padding:0 14px}
.tab{padding:12px 16px;font-size:11.5px}
.print-bar{padding:10px 12px;gap:8px}
.print-bar .btn{flex:1}
.hdr-name,.hdr-role{display:none}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
+45
View File
@@ -0,0 +1,45 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['icons/favicon-32.png', 'icons/apple-touch-icon.png'],
manifest: {
name: 'BergVOLT Materialschein',
short_name: 'Materialschein',
description: 'Materialschein & Kabelrechner für BergVOLT Elektrotechnik',
start_url: '/',
scope: '/',
display: 'standalone',
background_color: '#0a1622',
theme_color: '#0a1622',
lang: 'de',
icons: [
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
},
workbox: {
// API-Aufrufe nie cachen immer live, sonst sieht man veraltete Projektdaten.
navigateFallbackDenylist: [/^\/api\//],
runtimeCaching: [
{
urlPattern: /^\/api\//,
handler: 'NetworkOnly'
}
]
}
})
],
server: {
proxy: {
'/api': 'http://localhost:8787'
}
}
});
+15
View File
@@ -0,0 +1,15 @@
# Nur für die lokale Entwicklung (postgres). Produktiv läuft gitops/stack.yml auf Portainer.
services:
db:
image: postgres:16-alpine
ports:
- "5433:5432"
environment:
POSTGRES_USER: materialschein
POSTGRES_PASSWORD: dev
POSTGRES_DB: materialschein
volumes:
- dev-pgdata:/var/lib/postgresql/data
volumes:
dev-pgdata:
+38
View File
@@ -0,0 +1,38 @@
# git-backed Portainer-Stack für BergVOLT Materialschein.
# Nie im Portainer-Editor bearbeiten Änderungen gehen über commit + push
# (siehe deploy-standard README). image:-Tag wird von der CI per Commit-back
# auf :<version> gepinnt, der Bootstrap-Wert unten ist nur für den ersten Push.
networks:
dockernetwork:
external: true
services:
web:
image: git.prox.ws/bergvolt/materialschein:latest
container_name: materialschein
restart: unless-stopped
networks:
- dockernetwork
expose:
- "80"
environment:
DATABASE_URL: postgresql://materialschein:${POSTGRES_PASSWORD}@db:5432/materialschein
MSAL_CLIENT_ID: b1cd974c-77ce-4d93-b6e8-00e41a31adc8
MSAL_TENANT_ID: d9adeaaf-853b-4c4e-a822-8f1bedbb84f6
PORT: "80"
depends_on:
- db
db:
image: postgres:16-alpine
container_name: materialschein-db
restart: unless-stopped
networks:
- dockernetwork
environment:
POSTGRES_USER: materialschein
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: materialschein
volumes:
- /docker/materialschein/pgdata:/var/lib/postgresql/data
+1
View File
@@ -0,0 +1 @@
1.0
+29
View File
@@ -0,0 +1,29 @@
{
"name": "materialschein-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {
"@prisma/client": "^5.20.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"jwks-rsa": "^3.1.0",
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.7.4",
"prisma": "^5.20.0",
"tsx": "^4.19.1",
"typescript": "^5.6.2"
}
}
@@ -0,0 +1,38 @@
-- CreateEnum
CREATE TYPE "ProjektStatus" AS ENUM ('Aktiv', 'Abgeschlossen');
-- CreateTable
CREATE TABLE "projekte" (
"id" TEXT NOT NULL,
"titel" TEXT NOT NULL,
"auftraggeber" TEXT,
"datum" DATE,
"monteur" TEXT,
"notiz" TEXT,
"status" "ProjektStatus" NOT NULL DEFAULT 'Aktiv',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "projekte_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "material" (
"id" TEXT NOT NULL,
"projektId" TEXT NOT NULL,
"bezeichnung" TEXT NOT NULL,
"artNr" TEXT,
"menge" DECIMAL(10,2) NOT NULL,
"einheit" TEXT NOT NULL,
"notiz" TEXT,
"erstelltVon" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "material_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "material_projektId_idx" ON "material"("projektId");
-- AddForeignKey
ALTER TABLE "material" ADD CONSTRAINT "material_projektId_fkey" FOREIGN KEY ("projektId") REFERENCES "projekte"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+44
View File
@@ -0,0 +1,44 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum ProjektStatus {
Aktiv
Abgeschlossen
}
model Projekt {
id String @id @default(cuid())
titel String
auftraggeber String?
datum DateTime? @db.Date
monteur String?
notiz String?
status ProjektStatus @default(Aktiv)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
material Material[]
@@map("projekte")
}
model Material {
id String @id @default(cuid())
projektId String
projekt Projekt @relation(fields: [projektId], references: [id], onDelete: Cascade)
bezeichnung String
artNr String?
menge Decimal @db.Decimal(10, 2)
einheit String
notiz String?
erstelltVon String?
createdAt DateTime @default(now())
@@index([projektId])
@@map("material")
}
+54
View File
@@ -0,0 +1,54 @@
import type { NextFunction, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const CLIENT_ID = process.env.MSAL_CLIENT_ID ?? '';
const TENANT_ID = process.env.MSAL_TENANT_ID ?? '';
const jwks = jwksClient({
jwksUri: `https://login.microsoftonline.com/${TENANT_ID}/discovery/v2.0/keys`,
cache: true,
cacheMaxAge: 12 * 60 * 60 * 1000
});
function getKey(header: jwt.JwtHeader, callback: (err: Error | null, key?: string) => void) {
if (!header.kid) { callback(new Error('Kein kid im Token-Header')); return; }
jwks.getSigningKey(header.kid, (err, key) => {
if (err || !key) { callback(err ?? new Error('Kein Signing-Key gefunden')); return; }
callback(null, key.getPublicKey());
});
}
export interface AuthedRequest extends Request {
user?: { email: string; name: string; oid: string };
}
// Verifiziert das MSAL-Access-/ID-Token (Entra ID) im Authorization-Header.
// Prüft Signatur, Tenant (tid) und dass das Token für unsere App ausgestellt wurde (aud).
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Kein Token übermittelt.' });
return;
}
const token = header.slice('Bearer '.length);
jwt.verify(token, getKey, { algorithms: ['RS256'] }, (err, decoded) => {
if (err || !decoded || typeof decoded === 'string') {
res.status(401).json({ error: 'Token ungültig: ' + (err?.message ?? 'unbekannt') });
return;
}
const claims = decoded as jwt.JwtPayload & { tid?: string; aud?: string; preferred_username?: string; upn?: string; name?: string; oid?: string };
if (claims.tid !== TENANT_ID) {
res.status(401).json({ error: 'Falscher Tenant.' });
return;
}
if (claims.aud !== CLIENT_ID) {
res.status(401).json({ error: 'Token nicht für diese App ausgestellt.' });
return;
}
const email = claims.preferred_username ?? claims.upn ?? '';
req.user = { email, name: claims.name ?? email, oid: claims.oid ?? '' };
next();
});
}
+32
View File
@@ -0,0 +1,32 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import cors from 'cors';
import { requireAuth } from './auth.js';
import { projekteRouter } from './routes/projekte.js';
import { materialRouter } from './routes/material.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT ?? 80);
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const app = express();
app.use(cors());
app.use(express.json());
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.use('/api/projekte', requireAuth, projekteRouter);
app.use('/api/material', requireAuth, materialRouter);
// Statisch gebautes React/Vite-Frontend ausliefern.
app.use(express.static(PUBLIC_DIR));
// SPA-Fallback für client-seitiges Routing (react-router) alles außer /api/*.
app.get(/^(?!\/api\/).*/, (_req, res) => {
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
});
app.listen(PORT, () => {
console.log(`Materialschein-Server läuft auf Port ${PORT}`);
});
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
+60
View File
@@ -0,0 +1,60 @@
import { Router } from 'express';
import { prisma } from '../prisma.js';
import type { AuthedRequest } from '../auth.js';
export const materialRouter = Router();
// GET /api/material?projektId=xxx
materialRouter.get('/', async (req, res) => {
const projektId = String(req.query.projektId ?? '');
if (!projektId) { res.status(400).json({ error: 'projektId erforderlich.' }); return; }
const items = await prisma.material.findMany({
where: { projektId },
orderBy: { createdAt: 'asc' }
});
res.json(items);
});
materialRouter.post('/', async (req: AuthedRequest, res) => {
const { projektId, bezeichnung, artNr, menge, einheit, notiz } = req.body ?? {};
if (!projektId || !bezeichnung || menge === undefined || !einheit) {
res.status(400).json({ error: 'projektId, bezeichnung, menge und einheit sind erforderlich.' });
return;
}
const item = await prisma.material.create({
data: {
projektId,
bezeichnung,
artNr: artNr || null,
menge,
einheit,
notiz: notiz || null,
erstelltVon: req.user?.email ?? null
}
});
res.status(201).json(item);
});
materialRouter.patch('/:id', async (req, res) => {
const { id } = req.params;
const { menge, notiz } = req.body ?? {};
const data: Record<string, unknown> = {};
if (menge !== undefined) data.menge = menge;
if (notiz !== undefined) data.notiz = notiz || null;
try {
const item = await prisma.material.update({ where: { id }, data });
res.json(item);
} catch {
res.status(404).json({ error: 'Position nicht gefunden.' });
}
});
materialRouter.delete('/:id', async (req, res) => {
const { id } = req.params;
try {
await prisma.material.delete({ where: { id } });
res.status(204).end();
} catch {
res.status(404).json({ error: 'Position nicht gefunden.' });
}
});
+46
View File
@@ -0,0 +1,46 @@
import { Router } from 'express';
import { prisma } from '../prisma.js';
import type { AuthedRequest } from '../auth.js';
export const projekteRouter = Router();
projekteRouter.get('/', async (_req, res) => {
const projekte = await prisma.projekt.findMany({ orderBy: { createdAt: 'desc' } });
res.json(projekte);
});
projekteRouter.post('/', async (req: AuthedRequest, res) => {
const { titel, auftraggeber, datum, monteur, notiz } = req.body ?? {};
if (!titel || typeof titel !== 'string' || !titel.trim()) {
res.status(400).json({ error: 'Titel (Projekt/Baustelle) ist erforderlich.' });
return;
}
const projekt = await prisma.projekt.create({
data: {
titel: titel.trim(),
auftraggeber: auftraggeber || null,
datum: datum ? new Date(datum) : null,
monteur: monteur || null,
notiz: notiz || null
}
});
res.status(201).json(projekt);
});
projekteRouter.patch('/:id', async (req, res) => {
const { id } = req.params;
const { titel, auftraggeber, datum, monteur, notiz, status } = req.body ?? {};
const data: Record<string, unknown> = {};
if (titel !== undefined) data.titel = titel;
if (auftraggeber !== undefined) data.auftraggeber = auftraggeber || null;
if (datum !== undefined) data.datum = datum ? new Date(datum) : null;
if (monteur !== undefined) data.monteur = monteur || null;
if (notiz !== undefined) data.notiz = notiz || null;
if (status !== undefined) data.status = status;
try {
const projekt = await prisma.projekt.update({ where: { id }, data });
res.json(projekt);
} catch {
res.status(404).json({ error: 'Projekt nicht gefunden.' });
}
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}