import { useState, useEffect, useMemo, useCallback } from 'react' import { Link, useNavigate } from 'react-router-dom' import PreviewBlock from '../components/PreviewBlock' import PreviewLinkButton from '../components/PreviewLinkButton' import GiteaLinkButton from '../components/GiteaLinkButton' import { PageHeader, Tabs, Button, Badge, EmptyState, Field, Dialog } from '../components/ui' import { useWebsiteProjects } from '../hooks/useWebsiteProjects' import { useCustomers } from '../hooks/useCustomers' import { useWorkorders } from '../hooks/useWorkorders' import { useEmployees } from '../hooks/useEmployees' import { useAuth } from '../context/AuthContext' import { createProjectFromTemplate, syncGiteaRepos, updateWebsiteProject, archiveWebsiteProject, unarchiveWebsiteProject, importNewReposThrottled, } from '../lib/projectAdminApi' import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign' import { projectCounts, projectsInView, projectTabs, viewShowing, matchesSearch, hiddenMatches, VIEW_LABELS } from '../lib/projectList' import { PROJECT_TYPES, typeOf, projectActions, projectTicketRef, updatedLabel } from '../lib/projectRow' import { ticketPath } from '../lib/ticketRoutes' import { isRowActivation } from '../lib/rowClick' import { slugify } from '../lib/subdomain' const WORKORDER_FILTERS = { limit: 500 } export default function ProjectsPage() { const navigate = useNavigate() const { projects, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects() const { customers } = useCustomers() const { workorders } = useWorkorders(WORKORDER_FILTERS) const { employees } = useEmployees() const { isAdmin } = useAuth() const [loadedOnce, setLoadedOnce] = useState(false) const [filter, setFilter] = useState('all') const [search, setSearch] = useState('') const [customerFilter, setCustomerFilter] = useState('') const [employeeFilter, setEmployeeFilter] = useState('') const [showCreateModal, setShowCreateModal] = useState(false) const [createForm, setCreateForm] = useState({ displayName: '', subdomain: '', repoName: '', customerId: '', }) const [createLoading, setCreateLoading] = useState(false) const [createError, setCreateError] = useState('') const [syncLoading, setSyncLoading] = useState(false) const [notice, setNotice] = useState('') const [assignBusyId, setAssignBusyId] = useState('') const [hostBusyId, setHostBusyId] = useState('') const [refreshBusyId, setRefreshBusyId] = useState('') // Bearbeiten-Modal const [editProject, setEditProject] = useState(null) const [editForm, setEditForm] = useState({ projectName: '', subdomain: '', projectType: 'preview' }) const [editLoading, setEditLoading] = useState(false) const [editError, setEditError] = useState('') 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(() => { let cancelled = false fetchAllProjects().then(() => { if (!cancelled) setLoadedOnce(true) }) importNewReposThrottled() .then((r) => { if (r && !cancelled) fetchAllProjects() }) .catch(() => { /* Import-Fehler zeigt die Ticketkarte; die Liste bleibt wie sie ist */ }) return () => { cancelled = true } }, [fetchAllProjects]) const ticketMap = useMemo(() => { const map = {} for (const wo of workorders) { map[wo.$id] = wo.woid || wo.$id } return map }, [workorders]) // Ticket-Dokument-ID -> Workorder (fuer Mitarbeiter-Zuordnung ueber assignedTo) const workorderById = useMemo(() => { const map = {} for (const wo of workorders) map[wo.$id] = wo return map }, [workorders]) const sortedCustomers = useMemo( () => [...customers].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''))), [customers] ) const sortedEmployees = useMemo( () => [...employees].sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''))), [employees] ) // Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin) const counts = useMemo(() => projectCounts(projects, { isAdmin }), [projects, isAdmin]) // Filter neben der Kategorie: Textsuche (Titel/Subdomain/Repo), Kunde, Mitarbeiter (ueber das // zugeordnete Ticket -> assignedTo) const pageMatches = useCallback((p) => { if (!matchesSearch(p, search)) return false if (customerFilter && p.customerId !== customerFilter) return false if (employeeFilter) { const wo = p.ticketId ? workorderById[p.ticketId] : null if (!wo || wo.assignedTo !== employeeFilter) return false } return true }, [search, customerFilter, employeeFilter, workorderById]) const filteredProjects = useMemo( () => 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) => { e.preventDefault() setCreateLoading(true) setCreateError('') try { const result = await createProjectFromTemplate({ repoName: slugify(createForm.repoName) || slugify(createForm.subdomain) || slugify(createForm.displayName), displayName: createForm.displayName, subdomain: slugify(createForm.subdomain), customerId: createForm.customerId || '', ticketId: '', }) setShowCreateModal(false) setCreateForm({ displayName: '', subdomain: '', repoName: '', customerId: '' }) setNotice( result.previewUrl ? `Projekt angelegt - Website: ${result.previewUrl}` : 'Projekt (ohne Website) angelegt.' ) await fetchAllProjects() // Direkt zur Art-/Kunden-Zuordnung des neuen Repos if (result.projectId) navigate(`/projects/zuordnen/${result.projectId}`) } catch (err) { setCreateError(err.message || 'Projekt konnte nicht angelegt werden') // Das Repo kann trotz Fehler entstanden sein - Liste trotzdem neu laden fetchAllProjects() } finally { setCreateLoading(false) } } const handleSyncRepos = async () => { setSyncLoading(true) setNotice('') try { const r = await syncGiteaRepos() setNotice(`Gitea-Sync: ${r.synced ?? '?'} Repos (${r.created ?? 0} neu, ${r.updated ?? 0} aktualisiert)`) await fetchAllProjects() } catch (err) { setNotice('Gitea-Sync fehlgeschlagen: ' + (err.message || 'unbekannt')) } finally { setSyncLoading(false) } } const handleAssignCustomer = async (project, customerId) => { setAssignBusyId(project.$id) setNotice('') if (customerId) { const ticketId = ticketIdForCustomer(project, customerId, workorderById) const r = await assignProjects([project.$id], { customerId, ticketId }) const one = r.results[0] const summary = assignSummary(project.projectName, one.ok ? one.result : { error: one.error }) setNotice(summary.warnings.length ? `${summary.text} – ${summary.warnings.join(' – ')}` : summary.text) } 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 clearCustomer(project.$id) setNotice(r.success ? `"${project.projectName}" hat keinen Kunden mehr.` : 'Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt')) } await fetchAllProjects() setAssignBusyId('') } const handleHostNow = async (project) => { setHostBusyId(project.$id) setNotice('') try { // Deploy ueber die vorhandene PATCH-Route anstossen (Typ bleibt gleich) const r = await updateWebsiteProject(project.$id, { projectType: typeOf(project) }) setNotice( r.deploy?.deployed ? `"${project.projectName}" wird jetzt gehostet${r.previewUrl ? ': ' + r.previewUrl : ''}` : `Deploy angestossen, aber nicht bestaetigt: ${r.deploy?.error || 'siehe Server-Logs'}` ) await fetchAllProjects() } catch (err) { setNotice('Hosten fehlgeschlagen: ' + (err.message || 'unbekannt')) } finally { setHostBusyId('') } } // Bereits gehostetes Projekt erneut deployen (nach Website-Ueberarbeitung neu laden) const handleRefresh = async (project) => { setRefreshBusyId(project.$id) setNotice('') try { const r = await updateWebsiteProject(project.$id, { projectType: typeOf(project) }) setNotice( r.deploy?.deployed ? `"${project.projectName}" wurde aktualisiert${r.previewUrl ? ': ' + r.previewUrl : ''}` : `Aktualisierung angestossen, aber nicht bestaetigt: ${r.deploy?.error || 'siehe Server-Logs'}` ) await fetchAllProjects() } catch (err) { setNotice('Aktualisieren fehlgeschlagen: ' + (err.message || 'unbekannt')) } finally { setRefreshBusyId('') } } const openEdit = (project) => { setEditError('') setEditProject(project) setEditForm({ projectName: project.projectName || '', subdomain: project.subdomain || '', projectType: typeOf(project), }) } const handleEditSave = async (e) => { e.preventDefault() if (!editProject) return setEditLoading(true) setEditError('') try { const payload = { projectName: editForm.projectName, projectType: editForm.projectType, } if (editForm.projectType !== 'project') { payload.subdomain = slugify(editForm.subdomain) } const projectId = editProject.$id const result = await updateWebsiteProject(projectId, payload) setEditProject(null) let text = result.previewUrl ? `Projekt aktualisiert - ${result.isPublic ? 'oeffentlich' : 'Preview'}: ${result.previewUrl}` : 'Projekt aktualisiert.' // Faellt das Projekt aus der aktuellen Ansicht (z. B. zur Vorlage gemacht), dorthin wechseln, // wo es jetzt steht - sonst wirkt es verschwunden 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) { setEditError(err.message || 'Projekt konnte nicht aktualisiert werden') } finally { setEditLoading(false) } } const handleArchiveToggle = async (project) => { const isArchived = Boolean(project.archived) if (!isArchived && !window.confirm(`Repository "${project.projectName}" wirklich archivieren? Die Preview-Website wird entfernt.`)) { return } setArchiveBusy(true) setEditError('') try { if (isArchived) { await unarchiveWebsiteProject(project.$id) setNotice(`"${project.projectName}" reaktiviert.`) } else { await archiveWebsiteProject(project.$id) setNotice(`"${project.projectName}" archiviert.`) } setEditProject(null) await fetchAllProjects() } catch (err) { setEditError(err.message || 'Archivieren fehlgeschlagen') } finally { setArchiveBusy(false) } } // Typ-Optionen im Bearbeiten-Dialog: adminOnly-Typen (Vorlage) nur fuer Admins const typeOptions = Object.entries(PROJECT_TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly) let content if (!loadedOnce) { // Nur beim ersten Laden: spaeter bleibt die Tabelle beim Nachladen stehen content =
Wird geladen …
} else if (filteredProjects.length === 0) { content = } else { content = ( {filteredProjects.map((project) => { const actions = projectActions(project) const archived = Boolean(project.archived) const type = PROJECT_TYPES[actions.type] const name = project.projectName || project.giteaRepoName || '(ohne Name)' const ticketRef = projectTicketRef(project, ticketMap) const updated = updatedLabel(project.$updatedAt) return ( { if (isRowActivation(e)) openEdit(project) }} > ) })}
Projekt Kunde Art Adresse Ticket Status Zuletzt geändert Aktionen
{project.repoFullName &&
{project.repoFullName}
}
{type.short} {project.subdomain ? {project.subdomain} : ohne Website} {ticketRef ? {ticketRef} : -} {actions.hostedBadge && {actions.hostedBadge.text}}
{archived ? 'archiviert' : (project.status || '-')}
{updated.title ? updated.text : {updated.text}}
{actions.host && ( )} {actions.preview && } {actions.refresh && ( )} {actions.gitea && }
) } return (
} />
setSearch(e.target.value)} />
{notice &&

{notice}

} {hiddenHits.length > 0 && (

Weitere Treffer:{' '} {hiddenHits.map((h, i) => ( {i > 0 && ' · '} ))}

)} {error &&

{error}

} {content} {/* --- Neues Projekt --- */} {showCreateModal && ( setShowCreateModal(false)} footer={ <> } > {createError &&

{createError}

} setCreateForm((p) => ({ ...p, displayName: e.target.value }))} required /> setCreateForm((p) => ({ ...p, subdomain: e.target.value }))} placeholder="leer lassen = Projekt ohne Website" /> setCreateForm((p) => ({ ...p, repoName: e.target.value }))} placeholder="leer = aus Anzeigename/Subdomain abgeleitet" />
)} {/* --- Projekt bearbeiten --- */} {editProject && ( setEditProject(null)} footer={ <> {editProject.archived ? ( ) : ( )} {/* Im Fuss ausserhalb des
, verbunden ueber das Attribut form: Enter im Namensfeld speichert, im PreviewBlock nie */} } > {editProject.repoFullName &&

{editProject.repoFullName}

} {editError &&

{editError}

} setEditForm((p) => ({ ...p, projectName: e.target.value }))} required /> {editForm.projectType !== 'project' && ( '}.project.webklar.com - Aenderung wird neu deployt (alte Subdomain wird abgebaut).`} > setEditForm((p) => ({ ...p, subdomain: e.target.value }))} placeholder="z.B. musterfirma" /> )}
{editProject.archived &&

Archiviert: zum Bearbeiten oder Hosten erst reaktivieren.

} {/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */} {editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && (

Preview

)}
)}
) }