diff --git a/client/src/App.tsx b/client/src/App.tsx index 2e545d7..80c7c89 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 ( -
-
- - } /> - } /> - } /> - -
+ +
+
+ + } /> + } /> + } /> + } /> + } /> + +
+ ); } diff --git a/client/src/components/FavStar.tsx b/client/src/components/FavStar.tsx new file mode 100644 index 0000000..3e10c1c --- /dev/null +++ b/client/src/components/FavStar.tsx @@ -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 ( + + ); +} diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index f30a0dc..ee6ef31 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -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
- 📋 MATERIALSCHEIN + 📋 MATERIALSCHEIN + 🛒 BESTELLSCHEIN ⚖️ KABELRECHNER
diff --git a/client/src/components/KabelOptions.tsx b/client/src/components/KabelOptions.tsx deleted file mode 100644 index 0026fa9..0000000 --- a/client/src/components/KabelOptions.tsx +++ /dev/null @@ -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 => ( - - {KAB_DATA.filter(g.test).map(c => ( - - ))} - - ))} - - ); -} diff --git a/client/src/components/KabelSelect.tsx b/client/src/components/KabelSelect.tsx new file mode 100644 index 0000000..c972fb3 --- /dev/null +++ b/client/src/components/KabelSelect.tsx @@ -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 ( +
+
+ setQ(e.target.value)} + /> + {isKnown && } +
+ + {trimmed &&
{treffer === 0 ? 'Kein Kabel gefunden.' : `${treffer} Treffer`}
} +
+ ); +} diff --git a/client/src/data/kabelData.ts b/client/src/data/kabelData.ts index a29007f..84388b6 100644 --- a/client/src/data/kabelData.ts +++ b/client/src/data/kabelData.ts @@ -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 { diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index be04a07..0b0150b 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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 { const localToken = getLocalToken(); @@ -35,11 +35,16 @@ async function req(url: string, init?: RequestInit): Promise { export const api = { projekte: { - list: () => req('/api/projekte'), + list: (typ?: ScheinTyp) => req(typ ? `/api/projekte?typ=${typ}` : '/api/projekte'), create: (data: Partial) => req('/api/projekte', { method: 'POST', body: JSON.stringify(data) }), update: (id: string, data: Partial) => req(`/api/projekte/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), remove: (id: string) => req(`/api/projekte/${id}`, { method: 'DELETE' }) }, + favoriten: { + list: () => req<{ art: FavoritArt; refId: string }[]>('/api/favoriten'), + set: (art: FavoritArt, refId: string, favorit: boolean) => + req('/api/favoriten', { method: 'PUT', body: JSON.stringify({ art, refId, favorit }) }) + }, material: { list: (projektId: string) => req(`/api/material?projektId=${encodeURIComponent(projektId)}`), create: (data: { projektId: string; bezeichnung: string; artNr?: string; menge: number; einheit: string; notiz?: string }) => diff --git a/client/src/lib/favoriten.tsx b/client/src/lib/favoriten.tsx new file mode 100644 index 0000000..48a5592 --- /dev/null +++ b/client/src/lib/favoriten.tsx @@ -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({ 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>(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 {children}; +} + +export const useFavoriten = () => useContext(Ctx); diff --git a/client/src/lib/kabelSearch.ts b/client/src/lib/kabelSearch.ts new file mode 100644 index 0000000..60838ef --- /dev/null +++ b/client/src/lib/kabelSearch.ts @@ -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, '')); + }); +} diff --git a/client/src/lib/schein.ts b/client/src/lib/schein.ts new file mode 100644 index 0000000..a02868b --- /dev/null +++ b/client/src/lib/schein.ts @@ -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' }; +} diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 3d82750..7729ceb 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -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; } diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index e1c7c89..a8e997b 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -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(null); - const [filter, setFilter] = useState('Aktiv'); + const [filter, setFilter] = useState('Aktiv'); const [search, setSearch] = useState(''); const [showForm, setShowForm] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const [form, setForm] = useState({ titel: '', auftraggeber: '', 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 (
- setSearch(e.target.value)} /> + setSearch(e.target.value)} />
- {(['Aktiv', 'Abgeschlossen', 'Alle'] as const).map(f => ( - + {(['Aktiv', 'Abgeschlossen', 'Alle', 'Favoriten'] as const).map(f => ( + ))}
{projekte === null &&
} - {projekte !== null && gefiltert.length === 0 &&
Keine Projekte gefunden.
} + {projekte !== null && gefiltert.length === 0 &&
{filter === 'Favoriten' ? 'Noch keine Favoriten – mit dem Stern markierst du Scheine.' : `Keine ${info.plural} gefunden.`}
} {gefiltert.map(p => ( -
navigate(`/projekte/${p.id}`)}> +
navigate(`${info.detailBase}/${p.id}`)}> +
{p.titel || '(ohne Namen)'}
@@ -84,11 +98,11 @@ export default function Dashboard() { ))}
- {!showForm &&
setShowForm(true)}>+ Neues Projekt anlegen
} + {!showForm &&
setShowForm(true)}>+ Neuen {info.name} anlegen
} {showForm && (
-
Neues Projekt
+
Neuer {info.name}