diff --git a/src/components/TicketProjects.jsx b/src/components/TicketProjects.jsx
index 7d94978..9351f5f 100644
--- a/src/components/TicketProjects.jsx
+++ b/src/components/TicketProjects.jsx
@@ -1,28 +1,13 @@
import { useState, useEffect, useCallback } from 'react'
import { FaPlus, FaLink } from 'react-icons/fa6'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
-import { createProjectFromTemplate, importNewRepos } from '../lib/projectAdminApi'
-import { isOutsideOrg, assignSummary } from '../lib/projectAssign'
+import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi'
+import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign'
+import { slugify } from '../lib/subdomain'
import AssignedProjectCard from './AssignedProjectCard'
import { fetchServices, completeAssignment } from '../lib/projectKindsApi'
import { kindLabelFor } from '../lib/assignFlow'
-function slugify(v) {
- return String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100)
-}
-
-// Zuordnen lassen sich echte Repos des gleichen Kunden oder ohne Kunden, die nicht
-// archiviert, keine Vorlage und noch nicht an diesem Ticket sind
-function assignableProjects(all, ticket, assignedIds) {
- return (all || []).filter((p) =>
- p.repoFullName &&
- !assignedIds.has(p.$id) &&
- p.status !== 'archived' &&
- !p.isTemplate &&
- (!p.customerId || p.customerId === ticket.customerId)
- )
-}
-
// Projekte am Ticket: Liste, Zuweisen, Loesen und neues Projekt an einer Stelle.
// Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt.
export default function TicketProjects({ ticket }) {
@@ -57,7 +42,7 @@ export default function TicketProjects({ ticket }) {
setImporting(true)
setImportHint('')
try {
- await importNewRepos()
+ await importNewReposThrottled()
} catch (err) {
if (!cancelled) setImportHint(`Neue Repos konnten nicht geladen werden (${err.message}).`)
} finally {
@@ -73,10 +58,11 @@ export default function TicketProjects({ ticket }) {
setError('')
try {
await action()
- await load()
} catch (err) {
setError(err.message || 'Aktion fehlgeschlagen')
} finally {
+ // Auch nach einem Fehler neu laden: das Repo kann trotzdem entstanden sein
+ try { await load() } catch { /* Anzeige bleibt wie sie ist */ }
setBusy(false)
}
}
@@ -157,6 +143,8 @@ export default function TicketProjects({ ticket }) {
subdomain,
customerId: ticket.customerId || '',
ticketId: ticket.$id,
+ // Aus der Website-Vorlage mit Subdomain: die Art steht fest (sonst "Art fehlt")
+ kind: 'website',
})
setCreateForm({ displayName: '', subdomain: '' })
setShowCreate(false)
diff --git a/src/hooks/useWebsiteProjects.js b/src/hooks/useWebsiteProjects.js
index 1241fc1..0992339 100644
--- a/src/hooks/useWebsiteProjects.js
+++ b/src/hooks/useWebsiteProjects.js
@@ -59,5 +59,18 @@ export function useWebsiteProjects() {
}
}
- return { projects, loading, error, fetchAllProjects, fetchByTicketId, assignProjects, unassignProject }
+ // Projektliste "Kein Kunde": Kunde UND Ticket entfernen (bewusste Auswahl, mit Rueckfrage)
+ const clearCustomer = async (projectId) => {
+ try {
+ await databases.updateDocument(
+ DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, projectId,
+ { customerId: '', ticketId: '', updatedAt: new Date().toISOString() }
+ )
+ return { success: true }
+ } catch (err) {
+ return { success: false, error: err.message }
+ }
+ }
+
+ return { projects, loading, error, fetchAllProjects, fetchByTicketId, assignProjects, unassignProject, clearCustomer }
}
diff --git a/src/lib/projectAdminApi.js b/src/lib/projectAdminApi.js
index 37ef772..fc8f06f 100644
--- a/src/lib/projectAdminApi.js
+++ b/src/lib/projectAdminApi.js
@@ -1,4 +1,5 @@
import { account } from './appwrite'
+import { throttleRuns } from './throttleRuns'
const PROJECT_ADMIN_URL =
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
@@ -33,6 +34,11 @@ export async function importNewRepos() {
return adminFetch('/api/admin/gitea/import-repos', { method: 'POST' })
}
+// Jedes Aufklappen eines Tickets fragte frueher alle Gitea-Repos ab (serverseitig unter einer
+// globalen Sperre). Neue Repos kommen ohnehin per Webhook und 10-Minuten-Lauf - hier hoechstens
+// ein Abgleich pro Minute; ein laufender Abgleich wird geteilt. null = uebersprungen.
+export const importNewReposThrottled = throttleRuns(() => importNewRepos(), 60000)
+
export async function assignWebsiteProject(id, { customerId, ticketId }) {
return adminFetch(`/api/admin/website-projects/${id}/assign`, {
method: 'POST',
diff --git a/src/lib/projectAssign.js b/src/lib/projectAssign.js
index 9f0b7f9..dfc28de 100644
--- a/src/lib/projectAssign.js
+++ b/src/lib/projectAssign.js
@@ -6,8 +6,34 @@ export function isOutsideOrg(repoFullName) {
return org.toLowerCase() !== GITEA_ORG.toLowerCase()
}
+// "Preview einschalten" nur, solange das Projekt noch keine Website hat. Mit Subdomain laeuft die
+// Preview schon oder wird gerade gebaut (der Runner setzt 'deployed' erst danach) - Neubauen
+// geht dann ueber den Preview-Block. Archivierte Projekte erst reaktivieren.
export function needsPreviewButton(project) {
- return !project.previewUrl || project.status !== 'deployed'
+ if (project.archived) return false
+ return !project.subdomain || !project.previewUrl
+}
+
+// Zuordnen lassen sich echte Repos des gleichen Kunden oder ohne Kunden, die nicht archiviert,
+// keine Vorlage und noch nicht an diesem Ticket sind. Massgeblich ist das Flag `archived`
+// (nicht status): nach dem Reaktivieren blieb status frueher auf 'archived' stehen.
+export function assignableProjects(all, ticket, assignedIds) {
+ return (all || []).filter((p) =>
+ p.repoFullName &&
+ !assignedIds.has(p.$id) &&
+ !p.archived &&
+ !p.isTemplate &&
+ (!p.customerId || p.customerId === ticket.customerId)
+ )
+}
+
+// Kundenwechsel in der Projektliste: das bisherige Ticket nur behalten, wenn es dem neuen Kunden
+// gehoert - sonst haengt das Projekt von Kunde B am Ticket von Kunde A.
+export function ticketIdForCustomer(project, customerId, workorderById = {}) {
+ const ticketId = project?.ticketId || ''
+ if (!ticketId) return ''
+ if (customerId === project.customerId) return ticketId
+ return workorderById[ticketId]?.customerId === customerId ? ticketId : ''
}
export function assignSummary(projectName, result) {
diff --git a/src/lib/projectList.js b/src/lib/projectList.js
new file mode 100644
index 0000000..218a4db
--- /dev/null
+++ b/src/lib/projectList.js
@@ -0,0 +1,28 @@
+// Ansichten der Projektliste (Projekte-Seite). Jedes Gitea-Repo ist ein Projekt; "Aktive Projekte"
+// blendet Archiv und Vorlagen aus, "Alle inkl. Archiv" zeigt wirklich alles (Vorlagen nur Admins).
+
+const active = (p) => !p.archived && !p.isTemplate
+
+export function projectCounts(projects, { isAdmin = false } = {}) {
+ const list = projects || []
+ const act = list.filter(active)
+ return {
+ all: act.length,
+ unassigned: act.filter((p) => !p.customerId).length,
+ assigned: act.filter((p) => Boolean(p.customerId)).length,
+ archived: list.filter((p) => p.archived && (isAdmin || !p.isTemplate)).length,
+ templates: list.filter((p) => p.isTemplate && !p.archived).length,
+ everything: list.filter((p) => isAdmin || !p.isTemplate).length,
+ }
+}
+
+export function projectsInView(projects, filter, { isAdmin = false } = {}) {
+ const list = projects || []
+ if (filter === 'templates') return list.filter((p) => p.isTemplate && !p.archived)
+ if (filter === 'archived') return list.filter((p) => p.archived && (isAdmin || !p.isTemplate))
+ if (filter === 'everything') return list.filter((p) => isAdmin || !p.isTemplate)
+ const act = list.filter(active)
+ if (filter === 'unassigned') return act.filter((p) => !p.customerId)
+ if (filter === 'assigned') return act.filter((p) => Boolean(p.customerId))
+ return act
+}
diff --git a/src/lib/subdomain.js b/src/lib/subdomain.js
index 0a5b2bd..8237d28 100644
--- a/src/lib/subdomain.js
+++ b/src/lib/subdomain.js
@@ -5,18 +5,21 @@ const UMLAUT_MAP = {
ß: 'ss',
}
+// Subdomain/Repo-Kuerzel wie im Portal (slug.js): Umlaute ausgeschrieben statt "m-ller",
+// hoechstens 63 Zeichen (Laenge eines DNS-Labels), ohne Bindestrich am Rand.
+export function slugify(value) {
+ let v = String(value ?? '').trim().toLowerCase()
+ if (!v) return ''
+ v = v.replace(/[äöüß]/g, (ch) => UMLAUT_MAP[ch])
+ v = v.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
+ v = v.replace(/['’‘`]/g, '')
+ v = v.replace(/[^a-z0-9]+/g, '-')
+ v = v.replace(/^-+|-+$/g, '')
+ v = v.slice(0, 63)
+ return v.replace(/-+$/g, '')
+}
+
export function suggestSubdomain(customerName, projectName) {
const base = (customerName || '').trim() || (projectName || '').trim()
- if (!base) return ''
-
- let value = base.toLowerCase()
- value = value.replace(/[äöüß]/g, (ch) => UMLAUT_MAP[ch])
- value = value.normalize('NFD').replace(/[̀-ͯ]/g, '')
- value = value.replace(/['’‘`]/g, '')
- value = value.replace(/[^a-z0-9]+/g, '-')
- value = value.replace(/^-+|-+$/g, '')
- value = value.slice(0, 63)
- value = value.replace(/-+$/g, '')
-
- return value
+ return slugify(base)
}
diff --git a/src/lib/throttleRuns.js b/src/lib/throttleRuns.js
new file mode 100644
index 0000000..70b03bd
--- /dev/null
+++ b/src/lib/throttleRuns.js
@@ -0,0 +1,17 @@
+// Ein Aufruf hoechstens alle `maxAgeMs`: ein laufender Aufruf wird geteilt, danach liefert die
+// Funktion bis zum Ablauf null (uebersprungen). `now` nur fuer Tests.
+export function throttleRuns(run, maxAgeMs) {
+ let pending = null
+ let doneAt = 0
+ return ({ now = Date.now() } = {}) => {
+ if (pending) return pending
+ if (doneAt && now - doneAt < maxAgeMs) return Promise.resolve(null)
+ pending = Promise.resolve()
+ .then(run)
+ .finally(() => {
+ doneAt = Date.now()
+ pending = null
+ })
+ return pending
+ }
+}
diff --git a/src/pages/AssignProjectPage.jsx b/src/pages/AssignProjectPage.jsx
index 2128f03..9cf9f83 100644
--- a/src/pages/AssignProjectPage.jsx
+++ b/src/pages/AssignProjectPage.jsx
@@ -6,15 +6,13 @@ import { useAuth } from '../context/AuthContext'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { fetchServices, completeAssignment, fetchProject } from '../lib/projectKindsApi'
import { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } from '../lib/assignFlow'
-import { suggestSubdomain } from '../lib/subdomain'
+import { suggestSubdomain, slugify } from '../lib/subdomain'
import { isArchived } from '../lib/leads'
import { PageHeader, Card, Button, Field } from '../components/ui'
import { CreateCustomerForm } from './CustomersPage'
const todayIso = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
const fmtDate = (iso) => (iso ? new Date(iso).toLocaleDateString('de-DE') : '')
-const slugify = (v) =>
- String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63)
const STEP_TITLES = {
kind: 'Was ist das?',
@@ -95,11 +93,15 @@ export default function AssignProjectPage() {
const openTickets = useMemo(() => openTicketsFor(tickets, state.customerId), [tickets, state.customerId])
const isWebsiteWithCustomer = state.kind === 'website' && state.forCustomer
+ // Hat das Projekt schon eine Website (z. B. ueber "Neues Projekt" mit Subdomain angelegt), bleibt
+ // sie wie sie ist: kein Vorschlag, kein erneutes Einschalten (das benannte sie sonst um und
+ // stellte eine oeffentliche Website auf Login-Preview).
+ const hasPreviewAlready = Boolean(project?.subdomain)
useEffect(() => {
- if (state.step === 'summary' && isWebsiteWithCustomer && !subdomain) {
+ if (state.step === 'summary' && isWebsiteWithCustomer && !hasPreviewAlready && !subdomain) {
setSubdomain(suggestSubdomain('', repoName))
}
- }, [state.step, isWebsiteWithCustomer, subdomain, repoName])
+ }, [state.step, isWebsiteWithCustomer, hasPreviewAlready, subdomain, repoName])
const patch = (p) => setState((s) => ({ ...s, ...p }))
const go = (step) => { setError(null); patch({ step }) }
@@ -141,7 +143,7 @@ export default function AssignProjectPage() {
const { topic, requestedBy, startDate, details } = state.newTicket
body.newTicket = { topic, requestedBy, startDate, details }
}
- if (isWebsiteWithCustomer && previewOn && subdomain.trim()) {
+ if (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && subdomain.trim()) {
body.preview = { subdomain: slugify(subdomain) }
}
}
@@ -353,7 +355,12 @@ export default function AssignProjectPage() {
)}
- {isWebsiteWithCustomer && (
+ {isWebsiteWithCustomer && hasPreviewAlready && (
+
+ Preview läuft bereits unter https://{project.subdomain}.project.webklar.com und bleibt unverändert.
+
+ )}
+ {isWebsiteWithCustomer && !hasPreviewAlready && (
)}
-
-
+
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'}
+ {editProject.archived && (
+
+ Archiviert: zum Bearbeiten oder Hosten erst reaktivieren.
+
+ )}
- {editForm.projectType !== 'project' && (
+ {/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */}
+ {editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && (
diff --git a/tests/unit/importThrottle.test.mjs b/tests/unit/importThrottle.test.mjs
new file mode 100644
index 0000000..70f25bf
--- /dev/null
+++ b/tests/unit/importThrottle.test.mjs
@@ -0,0 +1,31 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadModule } from './load.mjs'
+
+const { throttleRuns } = await loadModule('src/lib/throttleRuns.js')
+
+test('Repo-Import hoechstens einmal pro Minute, laufender Abgleich wird geteilt', async () => {
+ let runs = 0
+ let release
+ const throttled = throttleRuns(() => { runs += 1; return new Promise((r) => { release = r }) }, 60000)
+ const a = throttled()
+ const b = throttled()
+ assert.equal(a, b)
+ await new Promise((r) => setTimeout(r, 0))
+ release({ created: 0 })
+ await a
+ assert.equal(runs, 1)
+ assert.equal(await throttled(), null)
+ assert.equal(runs, 1)
+ const later = throttled({ now: Date.now() + 61000 })
+ await new Promise((r) => setTimeout(r, 0))
+ release({ created: 1 })
+ assert.deepEqual(await later, { created: 1 })
+ assert.equal(runs, 2)
+})
+
+test('Fehler wird weitergereicht, der naechste Versuch wartet trotzdem die Minute ab', async () => {
+ const throttled = throttleRuns(async () => { throw new Error('weg') }, 60000)
+ await assert.rejects(throttled(), /weg/)
+ assert.equal(await throttled(), null)
+})
diff --git a/tests/unit/projectAssign.test.mjs b/tests/unit/projectAssign.test.mjs
index 7a3dfe7..489d23f 100644
--- a/tests/unit/projectAssign.test.mjs
+++ b/tests/unit/projectAssign.test.mjs
@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
-const { isOutsideOrg, needsPreviewButton, assignSummary, assignSequentially } = await loadModule(
+const { isOutsideOrg, needsPreviewButton, assignSummary, assignSequentially, assignableProjects, ticketIdForCustomer } = await loadModule(
'src/lib/projectAssign.js'
)
@@ -17,9 +17,36 @@ test('isOutsideOrg', () => {
})
test('needsPreviewButton', () => {
- assert.equal(needsPreviewButton({ previewUrl: 'https://x', status: 'deployed' }), false)
+ assert.equal(needsPreviewButton({ subdomain: 'x', previewUrl: 'https://x', status: 'deployed' }), false)
assert.equal(needsPreviewButton({ previewUrl: '', status: 'synced' }), true)
- assert.equal(needsPreviewButton({ previewUrl: 'https://x', status: 'pending' }), true)
+ // gerade angelegt, der Runner baut noch (status noch synced/pending): kein "Preview einschalten"
+ assert.equal(needsPreviewButton({ subdomain: 'x', previewUrl: 'https://x', status: 'synced' }), false)
+ assert.equal(needsPreviewButton({ subdomain: 'x', previewUrl: 'https://x', status: 'pending' }), false)
+ // archiviert: erst reaktivieren
+ assert.equal(needsPreviewButton({ archived: true, status: 'archived' }), false)
+})
+
+test('assignableProjects: Flag archived zaehlt, nicht status', () => {
+ const all = [
+ { $id: 'a', repoFullName: 'WEBklar/a', status: 'archived', archived: false }, // reaktiviert, alter status
+ { $id: 'b', repoFullName: 'JUSN/b', status: 'synced', archived: true }, // in Gitea archiviert
+ { $id: 'c', repoFullName: 'WEBklar/c', customerId: 'k2' }, // anderer Kunde
+ { $id: 'd', repoFullName: 'WEBklar/d', customerId: 'k1' },
+ { $id: 'e', repoFullName: 'WEBklar/e', isTemplate: true },
+ { $id: 'f', projectName: 'ohne Repo' },
+ { $id: 'g', repoFullName: 'WEBklar/g' },
+ ]
+ const ids = assignableProjects(all, { customerId: 'k1' }, new Set(['g'])).map((p) => p.$id)
+ assert.deepEqual(ids, ['a', 'd'])
+})
+
+test('ticketIdForCustomer: altes Ticket nur beim gleichen Kunden', () => {
+ const p = { customerId: 'A', ticketId: 'tA' }
+ const wo = { tA: { customerId: 'A' }, tB: { customerId: 'B' } }
+ assert.equal(ticketIdForCustomer(p, 'A', wo), 'tA')
+ assert.equal(ticketIdForCustomer(p, 'B', wo), '')
+ assert.equal(ticketIdForCustomer({ customerId: 'A', ticketId: 'tB' }, 'B', wo), 'tB')
+ assert.equal(ticketIdForCustomer({ customerId: '', ticketId: '' }, 'B', wo), '')
})
test('assignSummary', () => {
diff --git a/tests/unit/projectList.test.mjs b/tests/unit/projectList.test.mjs
new file mode 100644
index 0000000..275d693
--- /dev/null
+++ b/tests/unit/projectList.test.mjs
@@ -0,0 +1,36 @@
+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 P = [
+ { $id: 'a' },
+ { $id: 'b', customerId: 'k' },
+ { $id: 'c', archived: true },
+ { $id: 'd', isTemplate: true },
+ { $id: 'e', isTemplate: true, archived: true },
+]
+const ids = (list) => list.map((p) => p.$id)
+
+test('Aktive Projekte ohne Archiv und Vorlagen', () => {
+ assert.deepEqual(ids(projectsInView(P, 'all')), ['a', 'b'])
+ assert.deepEqual(ids(projectsInView(P, 'unassigned')), ['a'])
+ assert.deepEqual(ids(projectsInView(P, 'assigned')), ['b'])
+})
+
+test('Alle inkl. Archiv: wirklich alles (Vorlagen nur fuer Admins)', () => {
+ assert.deepEqual(ids(projectsInView(P, 'everything')), ['a', 'b', 'c'])
+ assert.deepEqual(ids(projectsInView(P, 'everything', { isAdmin: true })), ['a', 'b', 'c', 'd', 'e'])
+})
+
+test('Archiviert und Vorlagen', () => {
+ assert.deepEqual(ids(projectsInView(P, 'archived')), ['c'])
+ assert.deepEqual(ids(projectsInView(P, 'archived', { isAdmin: true })), ['c', 'e'])
+ assert.deepEqual(ids(projectsInView(P, 'templates')), ['d'])
+})
+
+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)
+})
diff --git a/tests/unit/subdomain.test.mjs b/tests/unit/subdomain.test.mjs
index bff1f6d..4daaab0 100644
--- a/tests/unit/subdomain.test.mjs
+++ b/tests/unit/subdomain.test.mjs
@@ -2,7 +2,14 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
-const { suggestSubdomain } = await loadModule('src/lib/subdomain.js')
+const { suggestSubdomain, slugify } = await loadModule('src/lib/subdomain.js')
+
+test('slugify schreibt Umlaute aus wie das Portal', () => {
+ assert.equal(slugify('Müller & Söhne'), 'mueller-soehne')
+ assert.equal(slugify(' ZZ Test Ablauf '), 'zz-test-ablauf')
+ assert.equal(slugify('x'.repeat(70)), 'x'.repeat(63))
+ assert.equal(slugify(null), '')
+})
test('Kundenname vor Projektname', () => {
assert.equal(suggestSubdomain('Döner Nation', 'D-ner-Nation'), 'doener-nation')