diff --git a/src/lib/projectList.js b/src/lib/projectList.js index 218a4db..53fac4f 100644 --- a/src/lib/projectList.js +++ b/src/lib/projectList.js @@ -26,3 +26,47 @@ export function projectsInView(projects, filter, { isAdmin = false } = {}) { if (filter === 'assigned') return act.filter((p) => Boolean(p.customerId)) return act } + +export const VIEW_LABELS = { all: 'Aktive Projekte', templates: 'Vorlagen', archived: 'Archiviert', everything: 'Alle inkl. Archiv' } + +// Ansichten, die ein Projekt zeigen koennen - die Vorlagen-Ansicht gibt es nur fuer Admins +const viewsFor = (isAdmin) => ['all', ...(isAdmin ? ['templates'] : []), 'archived', 'everything'] + +/** + * Ansicht, in der ein Projekt (z. B. nach dem Bearbeiten) zu sehen ist: die aktuelle, wenn es dort + * steht, sonst die erste passende. Ohne passende Ansicht bleibt es bei der aktuellen. + * Hintergrund: SHK-vorlage zur Vorlage gemacht -> verschwand kommentarlos aus "Aktive Projekte". + */ +export function viewShowing(project, filter, { isAdmin = false } = {}) { + if (!project) return filter + for (const view of [filter, ...viewsFor(isAdmin)]) { + if (view === 'templates' && !isAdmin) continue + if (projectsInView([project], view, { isAdmin }).length) return view + } + return filter +} + +/** Textsuche ueber Titel, Subdomain und Repo (leer passt immer). */ +export function matchesSearch(project, search) { + const q = String(search || '').trim().toLowerCase() + if (!q) return true + return [project.projectName, project.subdomain, project.repoFullName, project.giteaRepoName].some((v) => + String(v || '').toLowerCase().includes(q) + ) +} + +/** + * Treffer, die die aktuelle Ansicht ausblendet (Vorlagen nur fuer Admins, Archiv), je Ansicht gezaehlt. + * `matches` ist der Filter der Seite (Suche, Kunde, Mitarbeiter). + */ +export function hiddenMatches(projects, filter, matches, { isAdmin = false } = {}) { + if (filter === 'everything') return [] + const shown = new Set(projectsInView(projects, filter, { isAdmin }).map((p) => p.$id)) + const out = [] + for (const view of [...(isAdmin ? ['templates'] : []), 'archived']) { + if (view === filter) continue + const count = projectsInView(projects, view, { isAdmin }).filter((p) => !shown.has(p.$id) && matches(p)).length + if (count) out.push({ view, count }) + } + return out +} diff --git a/src/pages/ProjectsPage.jsx b/src/pages/ProjectsPage.jsx index 19acdd6..053f787 100644 --- a/src/pages/ProjectsPage.jsx +++ b/src/pages/ProjectsPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useCallback } from 'react' import { useNavigate } from 'react-router-dom' import PreviewBlock from '../components/PreviewBlock' import { @@ -27,7 +27,7 @@ import { importNewReposThrottled, } from '../lib/projectAdminApi' import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign' -import { projectCounts, projectsInView } from '../lib/projectList' +import { projectCounts, projectsInView, viewShowing, matchesSearch, hiddenMatches, VIEW_LABELS } from '../lib/projectList' import { slugify } from '../lib/subdomain' import PreviewLinkButton from '../components/PreviewLinkButton' import GiteaLinkButton from '../components/GiteaLinkButton' @@ -163,33 +163,28 @@ export default function ProjectsPage() { // Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin) const counts = useMemo(() => projectCounts(projects, { isAdmin }), [projects, isAdmin]) - const filteredProjects = useMemo(() => { - // 1) Kategorie (Dropdown) - let base = projectsInView(projects, filter, { isAdmin }) - - // 2) Textsuche (Titel/Subdomain/Repo) - const q = search.trim().toLowerCase() - if (q) { - base = base.filter((p) => - [p.projectName, p.subdomain, p.repoFullName, p.giteaRepoName].some((v) => - String(v || '').toLowerCase().includes(q) - ) - ) - } - - // 3) Kunde - if (customerFilter) base = base.filter((p) => p.customerId === customerFilter) - - // 4) Mitarbeiter (ueber das zugeordnete Ticket -> assignedTo) + // Filter neben der Kategorie: Textsuche (Titel/Subdomain/Repo), Kunde, Mitarbeiter (ueber das + // zugeordnete Ticket -> assignedTo) + const pageMatches = useCallback((p) => { + if (!matchesSearch(p, search)) return false + if (customerFilter && p.customerId !== customerFilter) return false if (employeeFilter) { - base = base.filter((p) => { - const wo = p.ticketId ? workorderById[p.ticketId] : null - return wo && wo.assignedTo === employeeFilter - }) + const wo = p.ticketId ? workorderById[p.ticketId] : null + if (!wo || wo.assignedTo !== employeeFilter) return false } + return true + }, [search, customerFilter, employeeFilter, workorderById]) - return base - }, [projects, filter, isAdmin, search, customerFilter, employeeFilter, workorderById]) + const filteredProjects = useMemo( + () => projectsInView(projects, filter, { isAdmin }).filter(pageMatches), + [projects, filter, isAdmin, pageMatches] + ) + + // Suchtreffer in ausgeblendeten Ansichten (Vorlagen, Archiv) - sonst wirkt ein Projekt verschwunden + const hiddenHits = useMemo( + () => (search.trim() ? hiddenMatches(projects, filter, pageMatches, { isAdmin }) : []), + [projects, filter, isAdmin, search, pageMatches] + ) const handleCreate = async (e) => { e.preventDefault() @@ -314,14 +309,21 @@ export default function ProjectsPage() { if (editForm.projectType !== 'project') { payload.subdomain = slugify(editForm.subdomain) } - const result = await updateWebsiteProject(editProject.$id, payload) + const projectId = editProject.$id + const result = await updateWebsiteProject(projectId, payload) setEditProject(null) - setNotice( - result.previewUrl - ? `Projekt aktualisiert - ${result.isPublic ? 'oeffentlich' : 'Preview'}: ${result.previewUrl}` - : 'Projekt aktualisiert.' - ) - await fetchAllProjects() + let text = result.previewUrl + ? `Projekt aktualisiert - ${result.isPublic ? 'oeffentlich' : 'Preview'}: ${result.previewUrl}` + : 'Projekt aktualisiert.' + // Faellt das Projekt aus der aktuellen Ansicht (z. B. zur Vorlage gemacht), dorthin wechseln, + // wo es jetzt steht - sonst wirkt es verschwunden + const fresh = (await fetchAllProjects()).find((p) => p.$id === projectId) + const view = viewShowing(fresh, filter, { isAdmin }) + if (fresh && view !== filter) { + setFilter(view) + text += ` – „${fresh.projectName}“ steht jetzt unter „${VIEW_LABELS[view]}“.` + } + setNotice(text) } catch (err) { setEditError(err.message || 'Projekt konnte nicht aktualisiert werden') } finally { @@ -448,6 +450,24 @@ export default function ProjectsPage() { )} + {hiddenHits.length > 0 && ( +
+ Weitere Treffer: + {hiddenHits.map((h, i) => ( + + {i > 0 && · } + + + ))} +
+ )} + {error && (
{error} diff --git a/tests/unit/projectList.test.mjs b/tests/unit/projectList.test.mjs index 275d693..ef6a5ed 100644 --- a/tests/unit/projectList.test.mjs +++ b/tests/unit/projectList.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { loadModule } from './load.mjs' -const { projectCounts, projectsInView } = await loadModule('src/lib/projectList.js') +const { projectCounts, projectsInView, viewShowing, matchesSearch, hiddenMatches } = await loadModule('src/lib/projectList.js') const P = [ { $id: 'a' }, @@ -34,3 +34,45 @@ test('Zaehler passen zu den Ansichten', () => { assert.deepEqual(projectCounts(P), { all: 2, unassigned: 1, assigned: 1, archived: 1, templates: 1, everything: 3 }) assert.deepEqual(projectCounts(P, { isAdmin: true }).everything, 5) }) + +// 04.10.2026: SHK-vorlage zur Vorlage gemacht -> verschwand kommentarlos aus "Aktive Projekte" +test('viewShowing: bleibt in der aktuellen Ansicht, wenn das Projekt dort steht', () => { + assert.equal(viewShowing(P[0], 'all', { isAdmin: true }), 'all') + assert.equal(viewShowing(P[1], 'assigned'), 'assigned') + assert.equal(viewShowing(P[3], 'everything', { isAdmin: true }), 'everything') +}) + +test('viewShowing: zur Vorlage gemacht -> Vorlagen, zurueck -> Aktive Projekte', () => { + assert.equal(viewShowing({ $id: 'x', isTemplate: true }, 'all', { isAdmin: true }), 'templates') + assert.equal(viewShowing({ $id: 'x', isTemplate: true }, 'unassigned', { isAdmin: true }), 'templates') + assert.equal(viewShowing({ $id: 'x' }, 'templates', { isAdmin: true }), 'all') + assert.equal(viewShowing({ $id: 'x', archived: true }, 'all'), 'archived') +}) + +test('viewShowing: ohne Admin keine Vorlagen-Ansicht, dann bleibt die Ansicht', () => { + assert.equal(viewShowing({ $id: 'x', isTemplate: true }, 'all'), 'all') + assert.equal(viewShowing(null, 'assigned'), 'assigned') +}) + +test('matchesSearch: Titel, Subdomain, Repo; leer passt immer', () => { + const p = { projectName: 'SHK-vorlage', subdomain: 'shk-vorlage', repoFullName: 'knso/SHK-vorlage', giteaRepoName: 'SHK-vorlage' } + assert.equal(matchesSearch(p, ''), true) + assert.equal(matchesSearch(p, ' shk '), true) + assert.equal(matchesSearch(p, 'knso/'), true) + assert.equal(matchesSearch(p, 'olzinger'), false) +}) + +test('hiddenMatches: Treffer, die die aktuelle Ansicht ausblendet', () => { + const list = [ + { $id: 'a', projectName: 'SHK Kunde' }, + { $id: 't', projectName: 'SHK-vorlage', isTemplate: true }, + { $id: 'r', projectName: 'SHK alt', archived: true }, + { $id: 'o', projectName: 'Olzinger' }, + ] + const shk = (p) => matchesSearch(p, 'shk') + assert.deepEqual(hiddenMatches(list, 'all', shk, { isAdmin: true }), [{ view: 'templates', count: 1 }, { view: 'archived', count: 1 }]) + assert.deepEqual(hiddenMatches(list, 'all', shk), [{ view: 'archived', count: 1 }], 'Vorlagen nur fuer Admins') + assert.deepEqual(hiddenMatches(list, 'templates', shk, { isAdmin: true }), [{ view: 'archived', count: 1 }]) + assert.deepEqual(hiddenMatches(list, 'everything', shk, { isAdmin: true }), [], 'zeigt schon alles') + assert.deepEqual(hiddenMatches(list, 'all', (p) => matchesSearch(p, 'olz'), { isAdmin: true }), []) +})