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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }}"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
client/dist/
|
||||||
|
server/dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
+29
@@ -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"]
|
||||||
@@ -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).
|
||||||
@@ -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>
|
||||||
@@ -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 |
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 & öffnen</button>
|
||||||
|
<button className="btn btn-ghost" onClick={() => setShowForm(false)}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{err && <div className="lerr" style={{ marginTop: 12 }}>{err}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1.0
|
||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient();
|
||||||
@@ -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.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user