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,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'
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user