Merge origin/test in feature/terminplaner-tp2 (Projekte: Anlegen, Archiv, Vorlage)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
4c3f67eadc
13 files changed
+424
-122
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+15
-12
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Ein Aufruf hoechstens alle `maxAgeMs`: ein laufender Aufruf wird geteilt, danach liefert die
|
||||
// Funktion bis zum Ablauf null (uebersprungen). Nach einem Fehler darf sofort neu versucht
|
||||
// werden (sonst verschwand der Hinweis und der Abgleich fiel eine Minute lang still aus).
|
||||
// `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)
|
||||
.then(
|
||||
(result) => { doneAt = Date.now(); pending = null; return result },
|
||||
(err) => { doneAt = 0; pending = null; throw err }
|
||||
)
|
||||
return pending
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user