Projekte im Ticketsystem: Anlegen, Archiv und Anzeige repariert

- Neues Projekt (Projekte-Seite): Repo-Name blieb nach dem ersten
  Buchstaben der Subdomain stehen ("m" statt "musterfirma")
- Ticket "Projekt waehlen": archivierte Repos nach Flag ausblenden, nicht
  nach status (reaktivierte fehlten, archivierte wurden angeboten)
- Projektliste: "Aktive Projekte" + neue Ansicht "Alle inkl. Archiv";
  Liste wird auch nach einem Anlege-Fehler neu geladen
- Kundenauswahl: "Kein Kunde" entfernt Kunde und Ticket wirklich (mit
  Rueckfrage); beim Kundenwechsel bleibt kein fremdes Ticket haengen
- Zuordnen-Assistent ueberschreibt eine vorhandene Website nicht mehr
- Neues Projekt am Ticket bekommt die Art Website
- Umlaute in Subdomains wie im Portal (mueller statt m-ller)
- Preview-Block nur mit gespeicherter Subdomain; archivierte Projekte
  erst reaktivieren, bevor sie bearbeitet werden
- Repo-Import beim Aufklappen hoechstens einmal pro Minute

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 13:23:01 +00:00
1 parent 78bc99a73b
commit f4c8b235b9
13 files changed
+269 -92

No files matched your search

+6
View File
@@ -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',
+27 -1
View File
@@ -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) {
+28
View File
@@ -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
}
+15 -12
View File
@@ -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)
}
+17
View File
@@ -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
}
}