Bestellschein-Tab, Favoriten je Benutzer (Scheine + Kabel), Kabelsuche, Loxone Link Kabel Cat.7
Build and Deploy / version (push) Successful in 1s
Build and Deploy / build (push) Successful in 14s
Build and Deploy / deploy (push) Successful in 1s

- Neuer Tab Bestellschein: gleiches Datenmodell/Funktionen wie Materialschein (Projekt.typ)
- Favoriten serverseitig pro Benutzer (Tabelle favoriten, /api/favoriten)
- Kabelauswahl mit Suchfeld + Favoriten (KabelSelect), Suche/Favoriten in der Referenztabelle
- Scheinsuche: mehrere Suchbegriffe, auch Notiz/Datum; Filter Favoriten

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Fidelis Huber
2026-10-09 20:04:52 +02:00
co-authored by Claude Sonnet 5.5
parent 9bd8ebeeb8
commit b6227eb4cf
20 changed files with 409 additions and 93 deletions
+7 -2
View File
@@ -6,6 +6,7 @@ import Header from './components/Header';
import Dashboard from './pages/Dashboard';
import ProjektDetail from './pages/ProjektDetail';
import Kabelrechner from './pages/Kabelrechner';
import { FavoritenProvider } from './lib/favoriten';
import { clearLocalAuth, getLocalUser } from './lib/localAuth';
export default function App() {
@@ -25,14 +26,18 @@ export default function App() {
return (
<BrowserRouter>
<FavoritenProvider>
<Header localUser={localUser} onLocalLogout={handleLocalLogout} />
<main>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/projekte/:id" element={<ProjektDetail />} />
<Route path="/" element={<Dashboard typ="Material" />} />
<Route path="/projekte/:id" element={<ProjektDetail typ="Material" />} />
<Route path="/bestellungen" element={<Dashboard typ="Bestell" />} />
<Route path="/bestellungen/:id" element={<ProjektDetail typ="Bestell" />} />
<Route path="/kabelrechner" element={<Kabelrechner />} />
</Routes>
</main>
</FavoritenProvider>
</BrowserRouter>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { MouseEvent } from 'react';
import { useFavoriten } from '../lib/favoriten';
import type { FavoritArt } from '../lib/types';
export default function FavStar({ art, refId, className = '' }: { art: FavoritArt; refId: string; className?: string }) {
const { isFav, toggle } = useFavoriten();
const fav = isFav(art, refId);
function onClick(e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
toggle(art, refId);
}
return (
<button
type="button"
className={'fav-star' + (fav ? ' on' : '') + (className ? ' ' + className : '')}
onClick={onClick}
title={fav ? 'Aus Favoriten entfernen' : 'Zu meinen Favoriten'}
aria-pressed={fav}
>
{fav ? '★' : '☆'}
</button>
);
}
+3 -1
View File
@@ -11,6 +11,7 @@ export default function Header({ localUser, onLocalLogout }: { localUser: string
const { instance, accounts } = useMsal();
const location = useLocation();
const onKabelrechner = location.pathname.startsWith('/kabelrechner');
const onBestell = location.pathname.startsWith('/bestellungen');
const account = accounts[0];
const displayName = localUser ?? account?.name ?? account?.username ?? '–';
const roleLine = localUser ? 'Lokales Konto' : (account?.username ?? '');
@@ -55,7 +56,8 @@ export default function Header({ localUser, onLocalLogout }: { localUser: string
<button className="btn-logout" onClick={logout}>Abmelden</button>
</header>
<div className="tabs">
<Link to="/" className={'tab' + (!onKabelrechner ? ' active' : '')}>📋 MATERIALSCHEIN</Link>
<Link to="/" className={'tab' + (!onKabelrechner && !onBestell ? ' active' : '')}>📋 MATERIALSCHEIN</Link>
<Link to="/bestellungen" className={'tab' + (onBestell ? ' active' : '')}>🛒 BESTELLSCHEIN</Link>
<Link to="/kabelrechner" className={'tab' + (onKabelrechner ? ' active' : '')}>⚖️ KABELRECHNER</Link>
</div>
</>
-33
View File
@@ -1,33 +0,0 @@
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' },
{ label: 'Netzwerkkabel (LAN Cat.5e / 6 / 7, Patchkabel)', test: c => c.badge === 'lan' },
{ label: 'Buskabel (KNX/EIB, J-Y(ST)Y)', test: c => c.badge === 'bus' },
{ label: 'Feldbus (Profibus DP, CAN-Bus)', test: c => c.badge === 'feldbus' },
{ label: 'LiYCY – Steuerleitung geschirmt', test: c => c.typ === 'LiYCY' },
{ label: 'LiYCY paarig – Steuerleitung geschirmt, paarweise', test: c => c.typ === 'LiYCY paarig' },
{ label: 'Loxone Kabel', test: c => c.badge === 'loxone' }
];
// Value = "Name|kgpm": Materialliste und Kabelrechner brauchen beide Name und Faktor (kg/m allein ist nicht eindeutig).
export default function KabelOptions() {
return (
<>
{GROUPS.map(g => (
<optgroup key={g.label} label={g.label}>
{KAB_DATA.filter(g.test).map(c => (
<option key={c.name} value={`${c.name}|${c.kgpm}`}>
{c.name}
</option>
))}
</optgroup>
))}
</>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { useMemo, useState, type ReactNode } from 'react';
import { KAB_DATA, type KabelTyp } from '../data/kabelData';
import { useFavoriten } from '../lib/favoriten';
import { kabelMatches } from '../lib/kabelSearch';
import FavStar from './FavStar';
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' },
{ label: 'Netzwerkkabel (LAN Cat.5e / 6 / 7, Patchkabel)', test: c => c.badge === 'lan' },
{ label: 'Buskabel (KNX/EIB, J-Y(ST)Y)', test: c => c.badge === 'bus' },
{ label: 'Feldbus (Profibus DP, CAN-Bus)', test: c => c.badge === 'feldbus' },
{ label: 'LiYCY – Steuerleitung geschirmt', test: c => c.typ === 'LiYCY' },
{ label: 'LiYCY paarig – Steuerleitung geschirmt, paarweise', test: c => c.typ === 'LiYCY paarig' },
{ label: 'Loxone Kabel', test: c => c.badge === 'loxone' }
];
const optValue = (c: KabelTyp) => `${c.name}|${c.kgpm}`;
// Kabelauswahl mit Suchfeld und persönlichen Favoriten. Value = "Name|kgpm" – Materialliste
// und Kabelrechner brauchen beide Name und Faktor (kg/m allein ist nicht eindeutig).
export default function KabelSelect({ value, onChange, children }: {
value: string;
onChange: (v: string) => void;
children?: ReactNode; // zusätzliche optgroups (z. B. "Eigenes kg/m …")
}) {
const { isFav } = useFavoriten();
const [q, setQ] = useState('');
const selectedName = value.split('|')[0];
const isKnown = KAB_DATA.some(c => c.name === selectedName);
const { favs, groups } = useMemo(() => {
// Das aktuell gewählte Kabel bleibt immer in der Liste, sonst würde das Select beim
// Weitertippen im Suchfeld auf "— wählen —" springen.
const visible = KAB_DATA.filter(c => kabelMatches(c.name + ' ' + c.typ, q) || c.name === selectedName);
return {
favs: visible.filter(c => isFav('KABEL', c.name)),
groups: GROUPS.map(g => ({ label: g.label, items: visible.filter(g.test) })).filter(g => g.items.length > 0)
};
}, [q, selectedName, isFav]);
const trimmed = q.trim();
const treffer = trimmed ? KAB_DATA.filter(c => kabelMatches(c.name + ' ' + c.typ, q)).length : 0;
return (
<div className="kab-select">
<div className="kab-search-row">
<input
type="search"
className="kab-search"
placeholder="Kabel suchen … z. B. NYM 5x2,5 · Cat7 · KNX · Loxone"
value={q}
onChange={e => setQ(e.target.value)}
/>
{isKnown && <FavStar art="KABEL" refId={selectedName} />}
</div>
<select value={value} onChange={e => onChange(e.target.value)}>
<option value="" disabled>— wählen —</option>
{favs.length > 0 && (
<optgroup label="★ Meine Favoriten">
{favs.map(c => <option key={'f' + c.name} value={optValue(c)}>{c.name}</option>)}
</optgroup>
)}
{groups.map(g => (
<optgroup key={g.label} label={g.label}>
{g.items.map(c => <option key={c.name} value={optValue(c)}>{c.name}</option>)}
</optgroup>
))}
{children}
</select>
{trimmed && <div className="kab-search-info">{treffer === 0 ? 'Kein Kabel gefunden.' : `${treffer} Treffer`}</div>}
</div>
);
}
+2 -1
View File
@@ -228,7 +228,8 @@ export const KAB_DATA: KabelTyp[] = [
{ typ: 'LiYCY paarig', name: 'LiYCY paarig 2×2×1 mm²', kgpm: 0.143, badge: 'liycy' },
{ typ: 'Loxone', name: 'Loxone Tree Kabel 2×2×0,8 + 2×1,5 mm² (100637)', kgpm: 0.095, badge: 'loxone' },
{ typ: 'Loxone', name: 'Loxone Tree Kabel LSZH 2×2×0,6 + 2×1,5 mm² (100394, Auslaufmodell)', kgpm: 0.104, badge: 'loxone' },
{ typ: 'Loxone', name: 'Loxone Audio Kabel 2×0,8 + 2×1,5 mm² (100606)', kgpm: 0.074, badge: 'loxone' }
{ typ: 'Loxone', name: 'Loxone Audio Kabel 2×0,8 + 2×1,5 mm² (100606)', kgpm: 0.074, badge: 'loxone' },
{ typ: 'Loxone', name: 'Loxone Link Kabel Cat.7 4×2×AWG23 (200129, Gewicht ≈ LAN 1000)', kgpm: 0.059, badge: 'loxone' }
];
export function kabelInfoForBez(bez: string): KabelTyp | undefined {
+7 -2
View File
@@ -1,6 +1,6 @@
import { msalInstance } from './authConfig';
import { getLocalToken } from './localAuth';
import type { MaterialItem, Projekt } from './types';
import type { FavoritArt, MaterialItem, Projekt, ScheinTyp } from './types';
async function authHeader(): Promise<HeadersInit> {
const localToken = getLocalToken();
@@ -35,11 +35,16 @@ async function req<T>(url: string, init?: RequestInit): Promise<T> {
export const api = {
projekte: {
list: () => req<Projekt[]>('/api/projekte'),
list: (typ?: ScheinTyp) => req<Projekt[]>(typ ? `/api/projekte?typ=${typ}` : '/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) }),
remove: (id: string) => req<void>(`/api/projekte/${id}`, { method: 'DELETE' })
},
favoriten: {
list: () => req<{ art: FavoritArt; refId: string }[]>('/api/favoriten'),
set: (art: FavoritArt, refId: string, favorit: boolean) =>
req<void>('/api/favoriten', { method: 'PUT', body: JSON.stringify({ art, refId, favorit }) })
},
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 }) =>
+43
View File
@@ -0,0 +1,43 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { api } from './api';
import type { FavoritArt } from './types';
interface FavoritenCtx {
isFav: (art: FavoritArt, refId: string) => boolean;
toggle: (art: FavoritArt, refId: string) => void;
}
const Ctx = createContext<FavoritenCtx>({ isFav: () => false, toggle: () => {} });
const key = (art: FavoritArt, refId: string) => `${art}:${refId}`;
// Favoriten gehören dem angemeldeten Benutzer (serverseitig gespeichert, damit sie auf
// Handy, Tablet und PC identisch sind).
export function FavoritenProvider({ children }: { children: ReactNode }) {
const [favs, setFavs] = useState<Set<string>>(new Set());
useEffect(() => {
api.favoriten.list()
.then(list => setFavs(new Set(list.map(f => key(f.art, f.refId)))))
.catch(() => { /* Favoriten sind optional – ohne sie funktioniert die App weiter */ });
}, []);
const isFav = useCallback((art: FavoritArt, refId: string) => favs.has(key(art, refId)), [favs]);
const toggle = useCallback((art: FavoritArt, refId: string) => {
const k = key(art, refId);
const neu = !favs.has(k);
const apply = (on: boolean) => setFavs(prev => {
const next = new Set(prev);
if (on) next.add(k); else next.delete(k);
return next;
});
apply(neu);
api.favoriten.set(art, refId, neu).catch(() => apply(!neu));
}, [favs]);
const value = useMemo(() => ({ isFav, toggle }), [isFav, toggle]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export const useFavoriten = () => useContext(Ctx);
+15
View File
@@ -0,0 +1,15 @@
// Tolerante Suche: Groß-/Kleinschreibung egal, "x" = "×", Komma = Punkt, mehrere
// Suchbegriffe müssen alle vorkommen ("nym 5x2,5" findet "NYM 5×2,5 mm²").
const norm = (s: string) => s.toLowerCase().replace(/×/g, 'x').replace(/,/g, '.').replace(/\s+/g, ' ');
export function kabelMatches(text: string, query: string): boolean {
const q = norm(query).trim();
if (!q) return true;
const hay = norm(text);
const hayCompact = hay.replace(/[\s.]/g, '');
return q.split(' ').every(term => {
if (hay.includes(term)) return true;
// "cat7" soll auch "Cat.7" finden – nur für Begriffe mit Buchstaben, nicht für Maße wie "2,5".
return /[a-z]/.test(term) && !/\d\.\d/.test(term) && hayCompact.includes(term.replace(/[\s.]/g, ''));
});
}
+19
View File
@@ -0,0 +1,19 @@
import type { ScheinTyp } from './types';
// Materialschein und Bestellschein verhalten sich identisch – nur Beschriftung, Routen
// und Dateiname des Exports unterscheiden sich.
export interface ScheinInfo {
name: string; // "Materialschein"
plural: string; // "Materialscheine"
liste: string; // "Materialliste"
druckTitel: string; // Kopf im Ausdruck
betreff: string; // "Materialverbrauch" / "Bestellung"
listBase: string; // Route der Übersicht
detailBase: string; // Route der Detailseite (ohne /:id)
}
export function scheinInfo(typ: ScheinTyp): ScheinInfo {
return typ === 'Bestell'
? { name: 'Bestellschein', plural: 'Bestellscheine', liste: 'Bestellliste', druckTitel: 'Bestellschein', betreff: 'Bestellung', listBase: '/bestellungen', detailBase: '/bestellungen' }
: { name: 'Materialschein', plural: 'Materialscheine', liste: 'Materialliste', druckTitel: 'Materialschein', betreff: 'Materialverbrauch', listBase: '/', detailBase: '/projekte' };
}
+4
View File
@@ -1,4 +1,7 @@
export type ProjektStatus = 'Aktiv' | 'Abgeschlossen';
// Materialschein und Bestellschein sind dasselbe Datenmodell, unterschieden über typ.
export type ScheinTyp = 'Material' | 'Bestell';
export type FavoritArt = 'SCHEIN' | 'KABEL';
export interface Projekt {
id: string;
@@ -9,6 +12,7 @@ export interface Projekt {
monteur: string | null;
notiz: string | null;
status: ProjektStatus;
typ: ScheinTyp;
createdAt: string;
updatedAt: string;
}
+33 -19
View File
@@ -1,8 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../lib/api';
import type { Projekt, ProjektStatus } from '../lib/types';
import type { Projekt, ProjektStatus, ScheinTyp } from '../lib/types';
import { useFavoriten } from '../lib/favoriten';
import MonteurField from '../components/MonteurField';
import FavStar from '../components/FavStar';
import { scheinInfo } from '../lib/schein';
function dd(s: string | null) {
if (!s) return '–';
@@ -11,21 +14,23 @@ function dd(s: string | null) {
return `${d}.${m}.${y}`;
}
export default function Dashboard() {
export default function Dashboard({ typ }: { typ: ScheinTyp }) {
const navigate = useNavigate();
const info = scheinInfo(typ);
const { isFav } = useFavoriten();
const [projekte, setProjekte] = useState<Projekt[] | null>(null);
const [filter, setFilter] = useState<ProjektStatus | 'Alle'>('Aktiv');
const [filter, setFilter] = useState<ProjektStatus | 'Alle' | 'Favoriten'>('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: '', auftragsNr: '', datum: new Date().toISOString().slice(0, 10), monteur: '', notiz: '' });
useEffect(() => { load(); }, []);
useEffect(() => { setProjekte(null); load(); }, [typ]); // eslint-disable-line react-hooks/exhaustive-deps
async function load() {
try {
setProjekte(await api.projekte.list());
setProjekte(await api.projekte.list(typ));
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
}
@@ -34,20 +39,28 @@ export default function Dashboard() {
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 ?? '') + ' ' + (p.auftragsNr ?? '')).toLowerCase().includes(q));
return items;
}, [projekte, filter, search]);
if (filter === 'Favoriten') items = items.filter(p => isFav('SCHEIN', p.id));
else if (filter !== 'Alle') items = items.filter(p => p.status === filter);
// Jeder Suchbegriff muss in irgendeinem Feld vorkommen (Reihenfolge egal).
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length) {
items = items.filter(p => {
const hay = [p.titel, p.auftraggeber, p.monteur, p.auftragsNr, p.notiz, p.datum ? dd(p.datum) : '', p.status].join(' ').toLowerCase();
return terms.every(t => hay.includes(t));
});
}
// Favoriten stehen oben, sonst bleibt die Sortierung (neueste zuerst) erhalten.
return [...items].sort((a, b) => Number(isFav('SCHEIN', b.id)) - Number(isFav('SCHEIN', a.id)));
}, [projekte, filter, search, isFav]);
async function createProjekt() {
if (!form.titel.trim()) { setErr('Bitte Projekt/Baustelle eingeben.'); return; }
setBusy(true);
setErr('');
try {
const projekt = await api.projekte.create(form);
const projekt = await api.projekte.create({ ...form, typ });
setShowForm(false);
navigate(`/projekte/${projekt.id}`);
navigate(`${info.detailBase}/${projekt.id}`);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
}
@@ -57,19 +70,20 @@ export default function Dashboard() {
return (
<div>
<div className="dash-toolbar">
<input className="dash-search" placeholder="Projekt suchen…" value={search} onChange={e => setSearch(e.target.value)} />
<input className="dash-search" placeholder={`${info.name} suchen … (Projekt, Kunde, Auftrags-Nr., Monteur)`} 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>
{(['Aktiv', 'Abgeschlossen', 'Alle', 'Favoriten'] as const).map(f => (
<button key={f} className={'dfilter' + (filter === f ? ' active' : '')} onClick={() => setFilter(f)}>{f === 'Favoriten' ? '★ Favoriten' : 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>}
{projekte !== null && gefiltert.length === 0 && <div className="empty-msg">{filter === 'Favoriten' ? 'Noch keine Favoriten – mit dem Stern markierst du Scheine.' : `Keine ${info.plural} gefunden.`}</div>}
{gefiltert.map(p => (
<div key={p.id} className="proj-card" onClick={() => navigate(`/projekte/${p.id}`)}>
<div key={p.id} className="proj-card" onClick={() => navigate(`${info.detailBase}/${p.id}`)}>
<FavStar art="SCHEIN" refId={p.id} />
<div className="proj-main">
<div className="proj-name">{p.titel || '(ohne Namen)'}</div>
<div className="proj-meta">
@@ -84,11 +98,11 @@ export default function Dashboard() {
))}
</div>
{!showForm && <div className="new-proj-card" style={{ marginTop: 14 }} onClick={() => setShowForm(true)}>+ Neues Projekt anlegen</div>}
{!showForm && <div className="new-proj-card" style={{ marginTop: 14 }} onClick={() => setShowForm(true)}>+ Neuen {info.name} anlegen</div>}
{showForm && (
<div className="card" style={{ marginTop: 14 }}>
<div className="card-title">Neues Projekt</div>
<div className="card-title">Neuer {info.name}</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" />
+28 -9
View File
@@ -1,6 +1,9 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { KAB_DATA } from '../data/kabelData';
import KabelOptions from '../components/KabelOptions';
import KabelSelect from '../components/KabelSelect';
import FavStar from '../components/FavStar';
import { useFavoriten } from '../lib/favoriten';
import { kabelMatches } from '../lib/kabelSearch';
export default function Kabelrechner() {
const [typSel, setTypSel] = useState('');
@@ -10,6 +13,16 @@ export default function Kabelrechner() {
const [useTara, setUseTara] = useState(false);
const [tara, setTara] = useState('');
const [typName, setTypName] = useState('Kabel');
const [refSearch, setRefSearch] = useState('');
const [nurFavoriten, setNurFavoriten] = useState(false);
const { isFav } = useFavoriten();
// Favoriten stehen oben, danach die Reihenfolge der Kabelliste.
const refRows = useMemo(() => {
let rows = KAB_DATA.filter(c => kabelMatches(c.name + ' ' + c.typ, refSearch));
if (nurFavoriten) rows = rows.filter(c => isFav('KABEL', c.name));
return [...rows].sort((a, b) => Number(isFav('KABEL', b.name)) - Number(isFav('KABEL', a.name)));
}, [refSearch, nurFavoriten, isFav]);
function onTypChange(v: string) {
setTypSel(v);
@@ -38,11 +51,9 @@ export default function Kabelrechner() {
<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 />
<KabelSelect value={typSel} onChange={onTypChange}>
<optgroup label="Manuell"><option value="custom">Eigenes kg/m …</option></optgroup>
</select>
</KabelSelect>
</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)} />
@@ -82,13 +93,21 @@ export default function Kabelrechner() {
</div>
<div className="card">
<div className="card-title">Referenztabelle – Meinhart Kabel Österreich GmbH</div>
<div className="card-title">Referenztabelle – Meinhart Kabel Österreich GmbH &amp; Loxone</div>
<div className="dash-toolbar" style={{ marginBottom: 10 }}>
<input className="dash-search" type="search" placeholder="Referenztabelle durchsuchen … z. B. LiYCY 4x0,25 · Cat7 · Profibus" value={refSearch} onChange={e => setRefSearch(e.target.value)} />
<div className="dash-filters">
<button className={'dfilter' + (nurFavoriten ? ' active' : '')} onClick={() => setNurFavoriten(v => !v)}>★ Nur Favoriten</button>
</div>
</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>
<thead><tr><th style={{ width: 34 }}></th><th>Typ</th><th>Bezeichnung</th><th>kg/m</th><th>bei 100 kg</th></tr></thead>
<tbody>
{KAB_DATA.map(c => (
{refRows.length === 0 && <tr><td colSpan={5} style={{ textAlign: 'center', opacity: .7 }}>{nurFavoriten && !refSearch ? 'Noch keine Favoriten – mit dem Stern markierst du Kabel.' : 'Kein Kabel gefunden.'}</td></tr>}
{refRows.map(c => (
<tr key={c.name} title={`Klick: ${c.name} übernehmen`} onClick={() => pickFromRef(c)}>
<td><FavStar art="KABEL" refId={c.name} /></td>
<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>
+17 -16
View File
@@ -2,18 +2,21 @@ import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import * as XLSX from 'xlsx';
import { api } from '../lib/api';
import type { MaterialItem, Projekt } from '../lib/types';
import type { MaterialItem, Projekt, ScheinTyp } from '../lib/types';
import { kabelInfoForBez } from '../data/kabelData';
import KabelOptions from '../components/KabelOptions';
import KabelSelect from '../components/KabelSelect';
import FavStar from '../components/FavStar';
import { scheinInfo } from '../lib/schein';
import MonteurField from '../components/MonteurField';
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;
const ddDate = (s: string) => { if (!s) return '—'; const [y, m, d] = s.split('-'); return `${d}.${m}.${y}`; };
export default function ProjektDetail() {
export default function ProjektDetail({ typ }: { typ: ScheinTyp }) {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const info = scheinInfo(typ);
const [projekt, setProjekt] = useState<Projekt | null>(null);
const [matList, setMatList] = useState<MaterialItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -87,7 +90,7 @@ export default function ProjektDetail() {
setDeleteBusy(true);
try {
await api.projekte.remove(id);
navigate('/');
navigate(info.listBase);
} catch (e) {
alert('Fehler beim Löschen: ' + (e instanceof Error ? e.message : e));
setDeleteBusy(false);
@@ -248,9 +251,9 @@ export default function ProjektDetail() {
ws['!cols'] = [{ wch: 10 }, { wch: 8 }, { wch: 8 }, { wch: 8 }, { wch: 8 }, { wch: 9 }, { wch: 9 }, { wch: 9 }, { wch: 7 }, { wch: 2 }, { wch: 55 }];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Aufmaß');
const safeName = (titel || 'Materialschein').replace(/[^\p{L}\p{N}_-]+/gu, '_');
const safeName = (titel || info.name).replace(/[^\p{L}\p{N}_-]+/gu, '_');
const safeAuftrag = auftragsNr ? auftragsNr.replace(/[^\p{L}\p{N}_-]+/gu, '_') + '_' : '';
XLSX.writeFile(wb, `Materialschein_${safeAuftrag}${safeName}.xlsm`, { bookType: 'xlsm' });
XLSX.writeFile(wb, `${info.name}_${safeAuftrag}${safeName}.xlsm`, { bookType: 'xlsm' });
}
if (loading) return <div className="empty-msg"><div className="spin" /></div>;
@@ -271,7 +274,7 @@ export default function ProjektDetail() {
</div>
<table className="pinfo">
<tbody>
<tr><td colSpan={2}>Materialschein</td></tr>
<tr><td colSpan={2}>{info.druckTitel}</td></tr>
{auftragsNr && <tr><td>Auftrags-Nr.</td><td>{auftragsNr}</td></tr>}
<tr><td>Datum</td><td>{ddDate(datum)}</td></tr>
<tr><td>Monteur</td><td>{monteur || '—'}</td></tr>
@@ -279,11 +282,12 @@ export default function ProjektDetail() {
</tbody>
</table>
</div>
<div className="psubject">Materialverbrauch – {titel || '(ohne Namen)'}</div>
<div className="psubject">{info.betreff} – {titel || '(ohne Namen)'}</div>
</div>
<div className="detail-back">
<Link className="btn-back" to="/">← Projekte</Link>
<Link className="btn-back" to={info.listBase}>← {info.plural}</Link>
<FavStar art="SCHEIN" refId={projekt.id} />
<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'}
@@ -320,11 +324,8 @@ export default function ProjektDetail() {
<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 className="field">Kabeltyp (Meinhart, Loxone)
<KabelSelect value={kabTyp} onChange={setKabTyp} />
</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)} />
@@ -381,7 +382,7 @@ export default function ProjektDetail() {
<div className="card">
<div className="card-title" style={{ justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<span>Materialliste</span>
<span>{info.liste}</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>
@@ -466,7 +467,7 @@ export default function ProjektDetail() {
)}
<div className="pfoot-note">
Materialschein erstellt über die BergVOLT Materialschein-App. Bei Rückfragen wende dich bitte
{info.name} erstellt über die BergVOLT Materialschein-App. Bei Rückfragen wende dich bitte
an den zuständigen Monteur.
</div>
</div>
+12
View File
@@ -335,3 +335,15 @@ table.pinfo td:last-child{text-align:right;font-weight:600}
.mat-table td.td-notiz{grid-area:notiz;flex-direction:column;align-items:stretch;gap:6px;border-bottom:none}
.mat-table td.td-notiz::before{content:'Notiz'}
}
/* Favoriten-Stern, Kabelsuche */
.fav-star{background:transparent;border:0;cursor:pointer;font-size:22px;line-height:1;padding:4px 6px;color:var(--muted);flex:0 0 auto}
.fav-star:hover{color:#f5b301}
.fav-star.on{color:#f5b301}
.kab-select{display:flex;flex-direction:column;gap:6px;min-width:0}
.kab-search-row{display:flex;gap:4px;align-items:center}
.kab-search{flex:1;min-width:0;height:38px;border:1.5px solid var(--bor);border-radius:9px;padding:0 12px;font-size:13px;background:var(--bg2);color:var(--text)}
.kab-search:focus{outline:none;border-color:var(--cyan)}
.kab-search-info{font-size:11px;color:var(--muted)}
.reftab td .fav-star{font-size:18px;padding:0 4px}
@media print{.fav-star,.kab-select .kab-search-row{display:none!important}}
@@ -0,0 +1,25 @@
-- CreateEnum
CREATE TYPE "ScheinTyp" AS ENUM ('Material', 'Bestell');
-- AlterTable
ALTER TABLE "projekte" ADD COLUMN "typ" "ScheinTyp" NOT NULL DEFAULT 'Material';
-- CreateTable
CREATE TABLE "favoriten" (
"id" TEXT NOT NULL,
"userKey" TEXT NOT NULL,
"art" TEXT NOT NULL,
"refId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "favoriten_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "projekte_typ_idx" ON "projekte"("typ");
-- CreateIndex
CREATE INDEX "favoriten_userKey_idx" ON "favoriten"("userKey");
-- CreateIndex
CREATE UNIQUE INDEX "favoriten_userKey_art_refId_key" ON "favoriten"("userKey", "art", "refId");
+22
View File
@@ -13,6 +13,12 @@ enum ProjektStatus {
Abgeschlossen
}
// Materialschein und Bestellschein teilen sich dieselbe Struktur (Kopfdaten + Positionen).
enum ScheinTyp {
Material
Bestell
}
model Projekt {
id String @id @default(cuid())
titel String
@@ -22,10 +28,12 @@ model Projekt {
monteur String?
notiz String?
status ProjektStatus @default(Aktiv)
typ ScheinTyp @default(Material)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
material Material[]
@@index([typ])
@@map("projekte")
}
@@ -56,3 +64,17 @@ model User {
@@map("users")
}
// Favoriten je Benutzer (Microsoft-Konto per oid, lokales Konto per Benutzername).
// art = SCHEIN (refId = Projekt-ID) oder KABEL (refId = Kabelname aus der Kabelliste).
model Favorit {
id String @id @default(cuid())
userKey String
art String
refId String
createdAt DateTime @default(now())
@@unique([userKey, art, refId])
@@index([userKey])
@@map("favoriten")
}
+2
View File
@@ -7,6 +7,7 @@ import { requireAuth } from './auth.js';
import { authRouter } from './routes/auth.js';
import { projekteRouter } from './routes/projekte.js';
import { materialRouter } from './routes/material.js';
import { favoritenRouter } from './routes/favoriten.js';
import { prisma } from './prisma.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -22,6 +23,7 @@ app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.use('/api/auth', authRouter);
app.use('/api/projekte', requireAuth, projekteRouter);
app.use('/api/material', requireAuth, materialRouter);
app.use('/api/favoriten', requireAuth, favoritenRouter);
// Statisch gebautes React/Vite-Frontend ausliefern.
app.use(express.static(PUBLIC_DIR));
+46
View File
@@ -0,0 +1,46 @@
import { Router } from 'express';
import { prisma } from '../prisma.js';
import type { AuthedRequest } from '../auth.js';
export const favoritenRouter = Router();
const ARTEN = ['SCHEIN', 'KABEL'];
// Favoriten gehören dem jeweiligen Benutzer: Microsoft-Konto über die oid, lokales
// Konto (keine oid) über den Benutzernamen.
function userKey(req: AuthedRequest): string | null {
const u = req.user;
if (!u) return null;
if (u.oid) return `ms:${u.oid}`;
if (u.email) return `local:${u.email.toLowerCase()}`;
return null;
}
// GET /api/favoriten → [{ art, refId }]
favoritenRouter.get('/', async (req: AuthedRequest, res) => {
const key = userKey(req);
if (!key) { res.status(401).json({ error: 'Benutzer unbekannt.' }); return; }
const items = await prisma.favorit.findMany({ where: { userKey: key }, select: { art: true, refId: true } });
res.json(items);
});
// PUT /api/favoriten { art, refId, favorit: boolean }
favoritenRouter.put('/', async (req: AuthedRequest, res) => {
const key = userKey(req);
if (!key) { res.status(401).json({ error: 'Benutzer unbekannt.' }); return; }
const { art, refId, favorit } = req.body ?? {};
if (!ARTEN.includes(art) || typeof refId !== 'string' || !refId || refId.length > 300 || typeof favorit !== 'boolean') {
res.status(400).json({ error: 'art, refId und favorit sind erforderlich.' });
return;
}
if (favorit) {
await prisma.favorit.upsert({
where: { userKey_art_refId: { userKey: key, art, refId } },
update: {},
create: { userKey: key, art, refId }
});
} else {
await prisma.favorit.deleteMany({ where: { userKey: key, art, refId } });
}
res.status(204).end();
});
+16 -4
View File
@@ -4,13 +4,23 @@ import type { AuthedRequest } from '../auth.js';
export const projekteRouter = Router();
projekteRouter.get('/', async (_req, res) => {
const projekte = await prisma.projekt.findMany({ orderBy: { createdAt: 'desc' } });
const SCHEIN_TYPEN = ['Material', 'Bestell'] as const;
type ScheinTypName = (typeof SCHEIN_TYPEN)[number];
const parseTyp = (v: unknown): ScheinTypName | undefined =>
SCHEIN_TYPEN.find(t => t === v);
// GET /api/projekte?typ=Material|Bestell (ohne typ: alle)
projekteRouter.get('/', async (req, res) => {
const typ = parseTyp(req.query.typ);
const projekte = await prisma.projekt.findMany({
where: typ ? { typ } : undefined,
orderBy: { createdAt: 'desc' }
});
res.json(projekte);
});
projekteRouter.post('/', async (req: AuthedRequest, res) => {
const { titel, auftraggeber, auftragsNr, datum, monteur, notiz } = req.body ?? {};
const { titel, auftraggeber, auftragsNr, datum, monteur, notiz, typ } = req.body ?? {};
if (!titel || typeof titel !== 'string' || !titel.trim()) {
res.status(400).json({ error: 'Titel (Projekt/Baustelle) ist erforderlich.' });
return;
@@ -22,7 +32,8 @@ projekteRouter.post('/', async (req: AuthedRequest, res) => {
auftragsNr: auftragsNr || null,
datum: datum ? new Date(datum) : null,
monteur: monteur || null,
notiz: notiz || null
notiz: notiz || null,
typ: parseTyp(typ) ?? 'Material'
}
});
res.status(201).json(projekt);
@@ -61,5 +72,6 @@ projekteRouter.delete('/:id', async (req, res) => {
return;
}
await prisma.projekt.delete({ where: { id } });
await prisma.favorit.deleteMany({ where: { art: 'SCHEIN', refId: id } });
res.status(204).end();
});