From 7e79dac6b46714ca21aa54e7b40a51fcdb81a3d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 17:44:55 +0000 Subject: [PATCH] Projektarten: Bereich Services Co-Authored-By: Claude Sonnet 5.5 --- src/App.jsx | 2 + src/components/Navbar.jsx | 2 + src/lib/servicesView.js | 34 +++++++++++++++ src/pages/ServicesPage.jsx | 73 ++++++++++++++++++++++++++++++++ tests/unit/servicesView.test.mjs | 40 +++++++++++++++++ 5 files changed, 151 insertions(+) create mode 100644 src/lib/servicesView.js create mode 100644 src/pages/ServicesPage.jsx create mode 100644 tests/unit/servicesView.test.mjs diff --git a/src/App.jsx b/src/App.jsx index 9487ca4..0c28219 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -12,6 +12,7 @@ import CustomerDetailPage from './pages/CustomerDetailPage' import FinancePage from './pages/FinancePage' import LegalPage from './pages/LegalPage' import AssignProjectPage from './pages/AssignProjectPage' +import ServicesPage from './pages/ServicesPage' import ServerpulsPage from './pages/ServerpulsPage' function ProtectedRoute({ children }) { @@ -49,6 +50,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> {/* Alte Links (z. B. die entfernten Seiten Assets, Reports, Docs, Roadmaps) landen bei den Tickets */} diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 3824ea6..74dccce 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -9,6 +9,7 @@ import { IconScale, IconActivityHeartbeat, IconFolders, + IconLayoutGrid, IconCalendar, IconSettings, IconLogout, @@ -56,6 +57,7 @@ export default function Sidebar() { { label: 'Tickets', href: '/tickets', icon: }, { label: 'Planboard', href: '/planboard', icon: }, { label: 'Projekte', href: '/projects', icon: }, + { label: 'Services', href: '/services', icon: }, { label: 'Kunden', href: '/customers', icon: }, // Leads liegen auf Eis (Lead-Automatik gestoppt 2026-09-30): grau, nicht klickbar { label: 'Leads', href: '/leads', icon: , paused: true }, diff --git a/src/lib/servicesView.js b/src/lib/servicesView.js new file mode 100644 index 0000000..8568ad3 --- /dev/null +++ b/src/lib/servicesView.js @@ -0,0 +1,34 @@ +// Aufbereitung der /api/admin/services-Antwort für die Seite "Services" +const STATUS_LABELS = { in_development: 'In Entwicklung' } + +export function servicesCards(api) { + if (!api) return [] + const groups = api.groups || {} + const featureDefs = api.features || {} + const cards = (api.kinds || []).map((k) => { + const projects = groups[k.key] || [] + return { + key: k.key, + label: k.label, + description: k.description || '', + count: projects.length, + features: (k.features || []).map((f) => ({ + label: featureDefs[f]?.label || f, + statusLabel: STATUS_LABELS[featureDefs[f]?.status] || '', + })), + projects, + } + }) + const missing = groups.missing || [] + if (missing.length) { + cards.push({ + key: 'missing', + label: 'Art fehlt', + description: 'Diese Projekte sind noch keiner Art zugeordnet.', + count: missing.length, + features: [], + projects: missing, + }) + } + return cards +} diff --git a/src/pages/ServicesPage.jsx b/src/pages/ServicesPage.jsx new file mode 100644 index 0000000..6ac9574 --- /dev/null +++ b/src/pages/ServicesPage.jsx @@ -0,0 +1,73 @@ +import { useState, useEffect, useMemo } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { FaSpinner } from 'react-icons/fa6' +import { fetchServices } from '../lib/projectKindsApi' +import { servicesCards } from '../lib/servicesView' +import { PageHeader, Card, Button, Badge } from '../components/ui' + +export default function ServicesPage() { + const navigate = useNavigate() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = async () => { + setLoading(true) + setError(null) + try { + setData(await fetchServices()) + } catch (err) { + setError(err.message || 'Services konnten nicht geladen werden.') + } finally { + setLoading(false) + } + } + useEffect(() => { load() }, []) + + const cards = useMemo(() => servicesCards(data), [data]) + + if (loading) return
+ + return ( +
+ + {error && ( + +
{error}
+ +
+ )} +
+ {cards.map((c) => ( + + {c.description &&
{c.description}
} + {c.features.length > 0 && ( +
+ {c.features.map((f) => ( + {f.label}{f.statusLabel ? ` · ${f.statusLabel}` : ''} + ))} +
+ )} +
+ {c.projects.map((p) => ( +
+
+
{p.projectName || p.repoFullName}
+
+ {[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'} +
+
+ {p.woid && Ticket #{p.woid}} + {c.key === 'missing' && ( + + )} +
+ ))} + {c.projects.length === 0 &&
Noch keine Projekte.
} +
+
+ ))} +
+
+ ) +} diff --git a/tests/unit/servicesView.test.mjs b/tests/unit/servicesView.test.mjs new file mode 100644 index 0000000..ef9762e --- /dev/null +++ b/tests/unit/servicesView.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { servicesCards } = await loadModule('src/lib/servicesView.js') + +const kinds = [ + { key: 'website', label: 'Website', description: 'Kundenwebsite', features: ['analyse'] }, + { key: 'app', label: 'App', description: 'Mobile App', features: [] }, +] +const features = { analyse: { label: 'Website-Analyse', status: 'in_development' } } + +test('servicesCards: Reihenfolge wie Katalog, Zählung, Funktionen', () => { + const cards = servicesCards({ + kinds, features, + groups: { app: [{ id: 'a' }, { id: 'b' }], website: [{ id: 'c' }] }, + }) + assert.deepEqual(cards.map((c) => c.key), ['website', 'app']) + assert.deepEqual(cards.map((c) => c.count), [1, 2]) + assert.equal(cards[0].description, 'Kundenwebsite') + assert.deepEqual(cards[0].features, [{ label: 'Website-Analyse', statusLabel: 'In Entwicklung' }]) + assert.deepEqual(cards[1].features, []) + assert.equal(cards[1].projects.length, 2) +}) + +test('servicesCards: Karte "Art fehlt" zuletzt, nur wenn nicht leer', () => { + const none = servicesCards({ kinds, features, groups: { missing: [] } }) + assert.equal(none.some((c) => c.key === 'missing'), false) + const cards = servicesCards({ kinds, features, groups: { missing: [{ id: 'x' }] } }) + const last = cards[cards.length - 1] + assert.equal(last.key, 'missing') + assert.equal(last.label, 'Art fehlt') + assert.equal(last.count, 1) +}) + +test('servicesCards: robust bei leerer Antwort und unbekannter Funktion', () => { + assert.deepEqual(servicesCards(null), []) + const cards = servicesCards({ kinds: [{ key: 'x', label: 'X', features: ['foo'] }] }) + assert.deepEqual(cards[0].features, [{ label: 'foo', statusLabel: '' }]) +})