Projekte: zur Vorlage gemachtes Projekt verschwindet nicht mehr
Vorlagen stehen absichtlich nur in der Ansicht "Vorlagen" (Admins). Nach
dem Speichern als Vorlage fiel das Projekt deshalb kommentarlos aus
"Aktive Projekte" (SHK-vorlage, 04.10.2026).
- nach dem Bearbeiten in die Ansicht wechseln, in der das Projekt jetzt
steht, mit Hinweis ("steht jetzt unter Vorlagen")
- Suche zeigt Treffer in ausgeblendeten Ansichten (Vorlagen, Archiv) mit
Klick zum Wechseln
- Hilfen viewShowing / matchesSearch / hiddenMatches in lib/projectList.js
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c262268015
commit
603d08c9c2
3 files changed
+140
-34
No files matched your search
@@ -26,3 +26,47 @@ export function projectsInView(projects, filter, { isAdmin = false } = {}) {
|
|||||||
if (filter === 'assigned') return act.filter((p) => Boolean(p.customerId))
|
if (filter === 'assigned') return act.filter((p) => Boolean(p.customerId))
|
||||||
return act
|
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
|
||||||
|
}
|
||||||
+53
-33
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import PreviewBlock from '../components/PreviewBlock'
|
import PreviewBlock from '../components/PreviewBlock'
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
importNewReposThrottled,
|
importNewReposThrottled,
|
||||||
} from '../lib/projectAdminApi'
|
} from '../lib/projectAdminApi'
|
||||||
import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign'
|
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 { slugify } from '../lib/subdomain'
|
||||||
import PreviewLinkButton from '../components/PreviewLinkButton'
|
import PreviewLinkButton from '../components/PreviewLinkButton'
|
||||||
import GiteaLinkButton from '../components/GiteaLinkButton'
|
import GiteaLinkButton from '../components/GiteaLinkButton'
|
||||||
@@ -163,33 +163,28 @@ export default function ProjectsPage() {
|
|||||||
// Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin)
|
// Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin)
|
||||||
const counts = useMemo(() => projectCounts(projects, { isAdmin }), [projects, isAdmin])
|
const counts = useMemo(() => projectCounts(projects, { isAdmin }), [projects, isAdmin])
|
||||||
|
|
||||||
const filteredProjects = useMemo(() => {
|
// Filter neben der Kategorie: Textsuche (Titel/Subdomain/Repo), Kunde, Mitarbeiter (ueber das
|
||||||
// 1) Kategorie (Dropdown)
|
// zugeordnete Ticket -> assignedTo)
|
||||||
let base = projectsInView(projects, filter, { isAdmin })
|
const pageMatches = useCallback((p) => {
|
||||||
|
if (!matchesSearch(p, search)) return false
|
||||||
// 2) Textsuche (Titel/Subdomain/Repo)
|
if (customerFilter && p.customerId !== customerFilter) return false
|
||||||
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)
|
|
||||||
if (employeeFilter) {
|
if (employeeFilter) {
|
||||||
base = base.filter((p) => {
|
const wo = p.ticketId ? workorderById[p.ticketId] : null
|
||||||
const wo = p.ticketId ? workorderById[p.ticketId] : null
|
if (!wo || wo.assignedTo !== employeeFilter) return false
|
||||||
return wo && wo.assignedTo === employeeFilter
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
}, [search, customerFilter, employeeFilter, workorderById])
|
||||||
|
|
||||||
return base
|
const filteredProjects = useMemo(
|
||||||
}, [projects, filter, isAdmin, search, customerFilter, employeeFilter, workorderById])
|
() => 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) => {
|
const handleCreate = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -314,14 +309,21 @@ export default function ProjectsPage() {
|
|||||||
if (editForm.projectType !== 'project') {
|
if (editForm.projectType !== 'project') {
|
||||||
payload.subdomain = slugify(editForm.subdomain)
|
payload.subdomain = slugify(editForm.subdomain)
|
||||||
}
|
}
|
||||||
const result = await updateWebsiteProject(editProject.$id, payload)
|
const projectId = editProject.$id
|
||||||
|
const result = await updateWebsiteProject(projectId, payload)
|
||||||
setEditProject(null)
|
setEditProject(null)
|
||||||
setNotice(
|
let text = result.previewUrl
|
||||||
result.previewUrl
|
? `Projekt aktualisiert - ${result.isPublic ? 'oeffentlich' : 'Preview'}: ${result.previewUrl}`
|
||||||
? `Projekt aktualisiert - ${result.isPublic ? 'oeffentlich' : 'Preview'}: ${result.previewUrl}`
|
: 'Projekt aktualisiert.'
|
||||||
: 'Projekt aktualisiert.'
|
// Faellt das Projekt aus der aktuellen Ansicht (z. B. zur Vorlage gemacht), dorthin wechseln,
|
||||||
)
|
// wo es jetzt steht - sonst wirkt es verschwunden
|
||||||
await fetchAllProjects()
|
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) {
|
} catch (err) {
|
||||||
setEditError(err.message || 'Projekt konnte nicht aktualisiert werden')
|
setEditError(err.message || 'Projekt konnte nicht aktualisiert werden')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -448,6 +450,24 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hiddenHits.length > 0 && (
|
||||||
|
<div className="text-center mb-2" data-hidden-hits>
|
||||||
|
<span className="text-grey">Weitere Treffer: </span>
|
||||||
|
{hiddenHits.map((h, i) => (
|
||||||
|
<span key={h.view}>
|
||||||
|
{i > 0 && <span className="text-grey"> · </span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{ padding: 0, border: 'none', background: 'none', color: 'var(--accent)', textDecoration: 'underline', cursor: 'pointer', font: 'inherit' }}
|
||||||
|
onClick={() => setFilter(h.view)}
|
||||||
|
>
|
||||||
|
{h.count} unter „{VIEW_LABELS[h.view]}“
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import test from 'node:test'
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { loadModule } from './load.mjs'
|
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 = [
|
const P = [
|
||||||
{ $id: 'a' },
|
{ $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), { all: 2, unassigned: 1, assigned: 1, archived: 1, templates: 1, everything: 3 })
|
||||||
assert.deepEqual(projectCounts(P, { isAdmin: true }).everything, 5)
|
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 }), [])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user