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,28 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { FaPlus, FaLink } from 'react-icons/fa6'
|
import { FaPlus, FaLink } from 'react-icons/fa6'
|
||||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
import { createProjectFromTemplate, importNewRepos } from '../lib/projectAdminApi'
|
import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi'
|
||||||
import { isOutsideOrg, assignSummary } from '../lib/projectAssign'
|
import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign'
|
||||||
|
import { slugify } from '../lib/subdomain'
|
||||||
import AssignedProjectCard from './AssignedProjectCard'
|
import AssignedProjectCard from './AssignedProjectCard'
|
||||||
import { fetchServices, completeAssignment } from '../lib/projectKindsApi'
|
import { fetchServices, completeAssignment } from '../lib/projectKindsApi'
|
||||||
import { kindLabelFor } from '../lib/assignFlow'
|
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.
|
// 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.
|
// Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt.
|
||||||
export default function TicketProjects({ ticket }) {
|
export default function TicketProjects({ ticket }) {
|
||||||
@@ -57,7 +42,7 @@ export default function TicketProjects({ ticket }) {
|
|||||||
setImporting(true)
|
setImporting(true)
|
||||||
setImportHint('')
|
setImportHint('')
|
||||||
try {
|
try {
|
||||||
await importNewRepos()
|
await importNewReposThrottled()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setImportHint(`Neue Repos konnten nicht geladen werden (${err.message}).`)
|
if (!cancelled) setImportHint(`Neue Repos konnten nicht geladen werden (${err.message}).`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -73,10 +58,11 @@ export default function TicketProjects({ ticket }) {
|
|||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
await action()
|
await action()
|
||||||
await load()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Aktion fehlgeschlagen')
|
setError(err.message || 'Aktion fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
|
// Auch nach einem Fehler neu laden: das Repo kann trotzdem entstanden sein
|
||||||
|
try { await load() } catch { /* Anzeige bleibt wie sie ist */ }
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +135,10 @@ export default function TicketProjects({ ticket }) {
|
|||||||
|
|
||||||
const create = (e) => {
|
const create = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (!slugify(createForm.subdomain)) {
|
||||||
|
setError('Bitte eine gültige Subdomain eingeben (Buchstaben, Ziffern, Bindestrich).')
|
||||||
|
return
|
||||||
|
}
|
||||||
run(async () => {
|
run(async () => {
|
||||||
const subdomain = slugify(createForm.subdomain)
|
const subdomain = slugify(createForm.subdomain)
|
||||||
await createProjectFromTemplate({
|
await createProjectFromTemplate({
|
||||||
@@ -157,6 +147,8 @@ export default function TicketProjects({ ticket }) {
|
|||||||
subdomain,
|
subdomain,
|
||||||
customerId: ticket.customerId || '',
|
customerId: ticket.customerId || '',
|
||||||
ticketId: ticket.$id,
|
ticketId: ticket.$id,
|
||||||
|
// Aus der Website-Vorlage mit Subdomain: die Art steht fest (sonst "Art fehlt")
|
||||||
|
kind: 'website',
|
||||||
})
|
})
|
||||||
setCreateForm({ displayName: '', subdomain: '' })
|
setCreateForm({ displayName: '', subdomain: '' })
|
||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { account } from './appwrite'
|
import { account } from './appwrite'
|
||||||
|
import { throttleRuns } from './throttleRuns'
|
||||||
|
|
||||||
const PROJECT_ADMIN_URL =
|
const PROJECT_ADMIN_URL =
|
||||||
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
|
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' })
|
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 }) {
|
export async function assignWebsiteProject(id, { customerId, ticketId }) {
|
||||||
return adminFetch(`/api/admin/website-projects/${id}/assign`, {
|
return adminFetch(`/api/admin/website-projects/${id}/assign`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -6,8 +6,34 @@ export function isOutsideOrg(repoFullName) {
|
|||||||
return org.toLowerCase() !== GITEA_ORG.toLowerCase()
|
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) {
|
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) {
|
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',
|
ß: '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) {
|
export function suggestSubdomain(customerName, projectName) {
|
||||||
const base = (customerName || '').trim() || (projectName || '').trim()
|
const base = (customerName || '').trim() || (projectName || '').trim()
|
||||||
if (!base) return ''
|
return slugify(base)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,15 +6,13 @@ import { useAuth } from '../context/AuthContext'
|
|||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||||
import { fetchServices, completeAssignment, fetchProject } from '../lib/projectKindsApi'
|
import { fetchServices, completeAssignment, fetchProject } from '../lib/projectKindsApi'
|
||||||
import { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } from '../lib/assignFlow'
|
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 { isArchived } from '../lib/leads'
|
||||||
import { PageHeader, Card, Button, Field } from '../components/ui'
|
import { PageHeader, Card, Button, Field } from '../components/ui'
|
||||||
import { CreateCustomerForm } from './CustomersPage'
|
import { CreateCustomerForm } from './CustomersPage'
|
||||||
|
|
||||||
const todayIso = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
const todayIso = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||||
const fmtDate = (iso) => (iso ? new Date(iso).toLocaleDateString('de-DE') : '')
|
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 = {
|
const STEP_TITLES = {
|
||||||
kind: 'Was ist das?',
|
kind: 'Was ist das?',
|
||||||
@@ -95,11 +93,15 @@ export default function AssignProjectPage() {
|
|||||||
const openTickets = useMemo(() => openTicketsFor(tickets, state.customerId), [tickets, state.customerId])
|
const openTickets = useMemo(() => openTicketsFor(tickets, state.customerId), [tickets, state.customerId])
|
||||||
|
|
||||||
const isWebsiteWithCustomer = state.kind === 'website' && state.forCustomer
|
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(() => {
|
useEffect(() => {
|
||||||
if (state.step === 'summary' && isWebsiteWithCustomer && !subdomain) {
|
if (state.step === 'summary' && isWebsiteWithCustomer && !hasPreviewAlready && !subdomain) {
|
||||||
setSubdomain(suggestSubdomain('', repoName))
|
setSubdomain(suggestSubdomain('', repoName))
|
||||||
}
|
}
|
||||||
}, [state.step, isWebsiteWithCustomer, subdomain, repoName])
|
}, [state.step, isWebsiteWithCustomer, hasPreviewAlready, subdomain, repoName])
|
||||||
|
|
||||||
const patch = (p) => setState((s) => ({ ...s, ...p }))
|
const patch = (p) => setState((s) => ({ ...s, ...p }))
|
||||||
const go = (step) => { setError(null); patch({ step }) }
|
const go = (step) => { setError(null); patch({ step }) }
|
||||||
@@ -141,7 +143,7 @@ export default function AssignProjectPage() {
|
|||||||
const { topic, requestedBy, startDate, details } = state.newTicket
|
const { topic, requestedBy, startDate, details } = state.newTicket
|
||||||
body.newTicket = { topic, requestedBy, startDate, details }
|
body.newTicket = { topic, requestedBy, startDate, details }
|
||||||
}
|
}
|
||||||
if (isWebsiteWithCustomer && previewOn && subdomain.trim()) {
|
if (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && subdomain.trim()) {
|
||||||
body.preview = { subdomain: slugify(subdomain) }
|
body.preview = { subdomain: slugify(subdomain) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,7 +355,14 @@ export default function AssignProjectPage() {
|
|||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
{isWebsiteWithCustomer && (
|
{isWebsiteWithCustomer && hasPreviewAlready && (
|
||||||
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
|
{['deployed', 'ready'].includes(String(project.status || '').toLowerCase())
|
||||||
|
? <>Preview läuft bereits unter https://{project.subdomain}.project.webklar.com und bleibt unverändert.</>
|
||||||
|
: <>Preview ist unter https://{project.subdomain}.project.webklar.com eingerichtet (Status: {project.status || 'unbekannt'}). Neu bauen geht im Projekt über „Preview".</>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isWebsiteWithCustomer && !hasPreviewAlready && (
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<label className="flex gap-2" style={{ alignItems: 'center' }}>
|
<label className="flex gap-2" style={{ alignItems: 'center' }}>
|
||||||
<input type="checkbox" checked={previewOn} onChange={(e) => setPreviewOn(e.target.checked)} />
|
<input type="checkbox" checked={previewOn} onChange={(e) => setPreviewOn(e.target.checked)} />
|
||||||
@@ -366,7 +375,7 @@ export default function AssignProjectPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button variant="primary" disabled={busy || !canFinish(state) || (isWebsiteWithCustomer && previewOn && !slugify(subdomain))} onClick={finish}>
|
<Button variant="primary" disabled={busy || !canFinish(state) || (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && !slugify(subdomain))} onClick={finish}>
|
||||||
{busy ? <FaSpinner className="spinner" /> : <><FaCheck /> Abschließen</>}
|
{busy ? <FaSpinner className="spinner" /> : <><FaCheck /> Abschließen</>}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+92
-76
@@ -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 {
|
||||||
@@ -24,19 +24,14 @@ import {
|
|||||||
updateWebsiteProject,
|
updateWebsiteProject,
|
||||||
archiveWebsiteProject,
|
archiveWebsiteProject,
|
||||||
unarchiveWebsiteProject,
|
unarchiveWebsiteProject,
|
||||||
|
importNewReposThrottled,
|
||||||
} from '../lib/projectAdminApi'
|
} from '../lib/projectAdminApi'
|
||||||
import { assignSummary } from '../lib/projectAssign'
|
import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign'
|
||||||
|
import { projectCounts, projectsInView, viewShowing, matchesSearch, hiddenMatches, VIEW_LABELS } from '../lib/projectList'
|
||||||
|
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'
|
||||||
|
|
||||||
function slugify(value) {
|
|
||||||
return String(value || '')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9-]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.slice(0, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORKORDER_FILTERS = { limit: 500 }
|
const WORKORDER_FILTERS = { limit: 500 }
|
||||||
|
|
||||||
// Projekt-Typen (= "Template"/Hosting-Modus)
|
// Projekt-Typen (= "Template"/Hosting-Modus)
|
||||||
@@ -91,7 +86,7 @@ function isProjectReady(project) {
|
|||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects()
|
const { projects, loading, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects()
|
||||||
const { customers } = useCustomers()
|
const { customers } = useCustomers()
|
||||||
const { workorders } = useWorkorders(WORKORDER_FILTERS)
|
const { workorders } = useWorkorders(WORKORDER_FILTERS)
|
||||||
const { employees } = useEmployees()
|
const { employees } = useEmployees()
|
||||||
@@ -122,8 +117,15 @@ export default function ProjectsPage() {
|
|||||||
const [editError, setEditError] = useState('')
|
const [editError, setEditError] = useState('')
|
||||||
const [archiveBusy, setArchiveBusy] = useState(false)
|
const [archiveBusy, setArchiveBusy] = useState(false)
|
||||||
|
|
||||||
|
// Erst die bekannte Liste zeigen, dann neue/archivierte Gitea-Repos abholen und neu laden -
|
||||||
|
// sonst fehlte ein frisch angelegtes Repo hier bis zum naechsten 10-Minuten-Lauf.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
fetchAllProjects()
|
fetchAllProjects()
|
||||||
|
importNewReposThrottled()
|
||||||
|
.then((r) => { if (r && !cancelled) fetchAllProjects() })
|
||||||
|
.catch(() => { /* Import-Fehler zeigt die Ticketkarte; die Liste bleibt wie sie ist */ })
|
||||||
|
return () => { cancelled = true }
|
||||||
}, [fetchAllProjects])
|
}, [fetchAllProjects])
|
||||||
|
|
||||||
const customerMap = useMemo(() => {
|
const customerMap = useMemo(() => {
|
||||||
@@ -158,53 +160,31 @@ export default function ProjectsPage() {
|
|||||||
[employees]
|
[employees]
|
||||||
)
|
)
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
// 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 active = projects.filter((p) => !p.archived && !p.isTemplate)
|
|
||||||
return {
|
|
||||||
all: active.length,
|
|
||||||
unassigned: active.filter((p) => !p.customerId).length,
|
|
||||||
assigned: active.filter((p) => Boolean(p.customerId)).length,
|
|
||||||
archived: projects.filter((p) => p.archived).length,
|
|
||||||
templates: projects.filter((p) => p.isTemplate && !p.archived).length,
|
|
||||||
}
|
|
||||||
}, [projects])
|
|
||||||
|
|
||||||
const filteredProjects = useMemo(() => {
|
// Filter neben der Kategorie: Textsuche (Titel/Subdomain/Repo), Kunde, Mitarbeiter (ueber das
|
||||||
// 1) Kategorie (Dropdown)
|
// zugeordnete Ticket -> assignedTo)
|
||||||
let base
|
const pageMatches = useCallback((p) => {
|
||||||
if (filter === 'templates') base = projects.filter((p) => p.isTemplate && !p.archived)
|
if (!matchesSearch(p, search)) return false
|
||||||
else if (filter === 'archived') base = projects.filter((p) => p.archived)
|
if (customerFilter && p.customerId !== customerFilter) return false
|
||||||
else {
|
|
||||||
const active = projects.filter((p) => !p.archived && !p.isTemplate)
|
|
||||||
if (filter === 'unassigned') base = active.filter((p) => !p.customerId)
|
|
||||||
else if (filter === 'assigned') base = active.filter((p) => Boolean(p.customerId))
|
|
||||||
else base = active
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
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, 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()
|
||||||
@@ -212,7 +192,7 @@ export default function ProjectsPage() {
|
|||||||
setCreateError('')
|
setCreateError('')
|
||||||
try {
|
try {
|
||||||
const result = await createProjectFromTemplate({
|
const result = await createProjectFromTemplate({
|
||||||
repoName: createForm.repoName || slugify(createForm.subdomain) || slugify(createForm.displayName),
|
repoName: slugify(createForm.repoName) || slugify(createForm.subdomain) || slugify(createForm.displayName),
|
||||||
displayName: createForm.displayName,
|
displayName: createForm.displayName,
|
||||||
subdomain: slugify(createForm.subdomain),
|
subdomain: slugify(createForm.subdomain),
|
||||||
customerId: createForm.customerId || '',
|
customerId: createForm.customerId || '',
|
||||||
@@ -230,6 +210,8 @@ export default function ProjectsPage() {
|
|||||||
if (result.projectId) navigate(`/projects/zuordnen/${result.projectId}`)
|
if (result.projectId) navigate(`/projects/zuordnen/${result.projectId}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCreateError(err.message || 'Projekt konnte nicht angelegt werden')
|
setCreateError(err.message || 'Projekt konnte nicht angelegt werden')
|
||||||
|
// Das Repo kann trotz Fehler entstanden sein - Liste trotzdem neu laden
|
||||||
|
fetchAllProjects()
|
||||||
} finally {
|
} finally {
|
||||||
setCreateLoading(false)
|
setCreateLoading(false)
|
||||||
}
|
}
|
||||||
@@ -253,13 +235,14 @@ export default function ProjectsPage() {
|
|||||||
setAssignBusyId(project.$id)
|
setAssignBusyId(project.$id)
|
||||||
setNotice('')
|
setNotice('')
|
||||||
if (customerId) {
|
if (customerId) {
|
||||||
const r = await assignProjects([project.$id], { customerId, ticketId: project.ticketId || '' })
|
const ticketId = ticketIdForCustomer(project, customerId, workorderById)
|
||||||
|
const r = await assignProjects([project.$id], { customerId, ticketId })
|
||||||
const one = r.results[0]
|
const one = r.results[0]
|
||||||
const summary = assignSummary(project.projectName, one.ok ? one.result : { error: one.error })
|
const summary = assignSummary(project.projectName, one.ok ? one.result : { error: one.error })
|
||||||
setNotice(summary.warnings.length ? `${summary.text} – ${summary.warnings.join(' – ')}` : summary.text)
|
setNotice(summary.warnings.length ? `${summary.text} – ${summary.warnings.join(' – ')}` : summary.text)
|
||||||
} else {
|
} else if (window.confirm(`Kunde von "${project.projectName}" entfernen? Der Kunde sieht das Projekt dann nicht mehr im Portal, das Ticket wird gelöst.`)) {
|
||||||
const r = await unassignProject(project.$id)
|
const r = await clearCustomer(project.$id)
|
||||||
if (!r.success) setNotice('Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt'))
|
setNotice(r.success ? `"${project.projectName}" hat keinen Kunden mehr.` : 'Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt'))
|
||||||
}
|
}
|
||||||
await fetchAllProjects()
|
await fetchAllProjects()
|
||||||
setAssignBusyId('')
|
setAssignBusyId('')
|
||||||
@@ -326,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 {
|
||||||
@@ -366,12 +356,13 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FILTER_OPTIONS = [
|
const FILTER_OPTIONS = [
|
||||||
{ value: 'all', label: `Alle Projekte (${counts.all})` },
|
{ value: 'all', label: `Aktive Projekte (${counts.all})` },
|
||||||
{ value: 'unassigned', label: `Unzugeordnet (${counts.unassigned})` },
|
{ value: 'unassigned', label: `Unzugeordnet (${counts.unassigned})` },
|
||||||
{ value: 'assigned', label: `Zugeordnet (${counts.assigned})` },
|
{ value: 'assigned', label: `Zugeordnet (${counts.assigned})` },
|
||||||
// Vorlagen-Ansicht nur fuer Admins
|
// Vorlagen-Ansicht nur fuer Admins
|
||||||
...(isAdmin ? [{ value: 'templates', label: `Vorlagen (${counts.templates})` }] : []),
|
...(isAdmin ? [{ value: 'templates', label: `Vorlagen (${counts.templates})` }] : []),
|
||||||
{ value: 'archived', label: `Archiviert (${counts.archived})` },
|
{ value: 'archived', label: `Archiviert (${counts.archived})` },
|
||||||
|
{ value: 'everything', label: `Alle inkl. Archiv (${counts.everything})` },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Typ-Optionen im Bearbeiten-Modal: adminOnly-Typen (Vorlage) nur fuer Admins
|
// Typ-Optionen im Bearbeiten-Modal: adminOnly-Typen (Vorlage) nur fuer Admins
|
||||||
@@ -459,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}
|
||||||
@@ -627,13 +636,9 @@ export default function ProjectsPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={createForm.subdomain}
|
value={createForm.subdomain}
|
||||||
onChange={(e) =>
|
// Repo-Name NICHT hier mitfuellen: nach dem ersten Buchstaben blieb er sonst
|
||||||
setCreateForm((p) => ({
|
// stehen (Repo "m" statt "musterfirma"). Leer = beim Anlegen aus Subdomain/Name.
|
||||||
...p,
|
onChange={(e) => setCreateForm((p) => ({ ...p, subdomain: e.target.value }))}
|
||||||
subdomain: e.target.value,
|
|
||||||
repoName: p.repoName || slugify(e.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="leer lassen = Projekt ohne Website"
|
placeholder="leer lassen = Projekt ohne Website"
|
||||||
/>
|
/>
|
||||||
<small className="text-grey">
|
<small className="text-grey">
|
||||||
@@ -747,12 +752,23 @@ export default function ProjectsPage() {
|
|||||||
? <><FaBoxOpen /> Reaktivieren</>
|
? <><FaBoxOpen /> Reaktivieren</>
|
||||||
: <><FaBoxArchive /> Repository archivieren</>}
|
: <><FaBoxArchive /> Repository archivieren</>}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn btn-dark" disabled={editLoading || archiveBusy}>
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-dark"
|
||||||
|
disabled={editLoading || archiveBusy || editProject.archived}
|
||||||
|
title={editProject.archived ? 'Archivierte Projekte erst reaktivieren' : undefined}
|
||||||
|
>
|
||||||
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'}
|
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{editProject.archived && (
|
||||||
|
<small className="text-grey" style={{ display: 'block', marginTop: 8 }}>
|
||||||
|
Archiviert: zum Bearbeiten oder Hosten erst reaktivieren.
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
{editForm.projectType !== 'project' && (
|
{/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */}
|
||||||
|
{editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && (
|
||||||
<div className="form-group" style={{ marginTop: 16 }}>
|
<div className="form-group" style={{ marginTop: 16 }}>
|
||||||
<label className="form-label">Preview</label>
|
<label className="form-label">Preview</label>
|
||||||
<PreviewBlock key={editProject.$id} projectId={editProject.$id} />
|
<PreviewBlock key={editProject.$id} projectId={editProject.$id} />
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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, danach darf sofort neu versucht werden', async () => {
|
||||||
|
let runs = 0
|
||||||
|
const throttled = throttleRuns(async () => { runs += 1; if (runs === 1) throw new Error('weg'); return { ok: true } }, 60000)
|
||||||
|
await assert.rejects(throttled(), /weg/)
|
||||||
|
assert.deepEqual(await throttled(), { ok: true })
|
||||||
|
assert.equal(runs, 2)
|
||||||
|
assert.equal(await throttled(), null)
|
||||||
|
})
|
||||||
@@ -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 { isOutsideOrg, needsPreviewButton, assignSummary, assignSequentially } = await loadModule(
|
const { isOutsideOrg, needsPreviewButton, assignSummary, assignSequentially, assignableProjects, ticketIdForCustomer } = await loadModule(
|
||||||
'src/lib/projectAssign.js'
|
'src/lib/projectAssign.js'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,9 +17,36 @@ test('isOutsideOrg', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('needsPreviewButton', () => {
|
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: '', 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', () => {
|
test('assignSummary', () => {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const { projectCounts, projectsInView, viewShowing, matchesSearch, hiddenMatches } = 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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 }), [])
|
||||||
|
})
|
||||||
@@ -2,7 +2,14 @@ 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 { 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', () => {
|
test('Kundenname vor Projektname', () => {
|
||||||
assert.equal(suggestSubdomain('Döner Nation', 'D-ner-Nation'), 'doener-nation')
|
assert.equal(suggestSubdomain('Döner Nation', 'D-ner-Nation'), 'doener-nation')
|
||||||
|
|||||||
Reference in new issue
Block a user