import { useState, useEffect, useMemo, useCallback } from 'react' import { useNavigate } from 'react-router-dom' import PreviewBlock from '../components/PreviewBlock' import { FaFolder, FaPlus, FaArrowsRotate, FaPen, FaBoxArchive, FaBoxOpen, FaGlobe, FaLock, FaCircleInfo, FaClone, } from 'react-icons/fa6' 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, viewShowing, matchesSearch, hiddenMatches, VIEW_LABELS } from '../lib/projectList' import { slugify } from '../lib/subdomain' import PreviewLinkButton from '../components/PreviewLinkButton' import GiteaLinkButton from '../components/GiteaLinkButton' const WORKORDER_FILTERS = { limit: 500 } // Projekt-Typen (= "Template"/Hosting-Modus) const TYPES = { preview: { label: 'Preview (mit Login)', short: 'Preview', badge: 'badge-info', icon: , hint: 'Login-geschuetzte Vorschau unter .project.webklar.com', }, website: { label: 'Oeffentliche Website', short: 'Website', badge: 'badge-ok', icon: , hint: 'Oeffentlich erreichbar ohne Login unter .project.webklar.com', }, template: { label: 'Vorlage', short: 'Vorlage', badge: 'badge-warn', icon: , hint: 'Website-Vorlage (login-geschuetzt) - erscheint nur fuer Admins in der Vorlagen-Ansicht', adminOnly: true, }, project: { label: 'Internes Projekt (kein Hosting)', short: 'Projekt', badge: 'badge-muted', icon: , hint: 'Nur Gitea-Repository, keine Website/Subdomain', }, } function typeOf(project) { if (project.isTemplate) return 'template' if (project.isPublic) return 'website' if (project.subdomain) return 'preview' return 'project' } // Typen mit Hosting (Subdomain + Deploy) const HOSTED_TYPES = new Set(['preview', 'website', 'template']) // Gleiche Logik wie isPreviewProjectReady im Backend (auth.js): // nur 'ready'/'deployed' gelten als tatsaechlich gehostet. function isProjectReady(project) { const st = String(project.provisioningStatus || project.status || '').toLowerCase() return ['ready', 'deployed'].includes(st) } export default function ProjectsPage() { const navigate = useNavigate() const { projects, loading, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects() const { customers } = useCustomers() const { workorders } = useWorkorders(WORKORDER_FILTERS) const { employees } = useEmployees() const { isAdmin } = useAuth() 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() importNewReposThrottled() .then((r) => { if (r && !cancelled) fetchAllProjects() }) .catch(() => { /* Import-Fehler zeigt die Ticketkarte; die Liste bleibt wie sie ist */ }) return () => { cancelled = true } }, [fetchAllProjects]) const customerMap = useMemo(() => { const map = {} for (const c of customers) { map[c.$id] = c.name || c.code || c.$id } return map }, [customers]) 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) } } const FILTER_OPTIONS = [ { value: 'all', label: `Aktive Projekte (${counts.all})` }, { value: 'unassigned', label: `Unzugeordnet (${counts.unassigned})` }, { value: 'assigned', label: `Zugeordnet (${counts.assigned})` }, // Vorlagen-Ansicht nur fuer Admins ...(isAdmin ? [{ value: 'templates', label: `Vorlagen (${counts.templates})` }] : []), { 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 const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly) return (

Website-Projekte

setSearch(e.target.value)} style={{ flex: '1 1 220px', minWidth: '180px', maxWidth: '320px' }} />
{notice && (
{notice}
)} {hiddenHits.length > 0 && (
Weitere Treffer: {hiddenHits.map((h, i) => ( {i > 0 && · } ))}
)} {error && (
{error}
)} {loading ? (

Projekte werden geladen...

) : filteredProjects.length === 0 ? (

{filter === 'archived' ? 'Keine archivierten Projekte.' : 'Keine Projekte gefunden.'}

) : (
{filteredProjects.map((project) => { const type = typeOf(project) const t = TYPES[type] const hosted = HOSTED_TYPES.has(type) const ready = isProjectReady(project) const needsHosting = hosted && !ready && !project.archived return (
{project.projectName || project.giteaRepoName || '(ohne Name)'}
{project.repoFullName && (
{project.repoFullName}
)}
{t.icon} {t.short} {hosted && !project.archived && ( {ready ? 'gehostet' : 'nicht deployt'} )}
{project.subdomain ? {project.subdomain} : ohne Website} {project.ticketId ? (ticketMap[project.ticketId] || project.ticketId) : -} {project.archived ? 'archiviert' : (project.status || '-')}
{needsHosting ? ( ) : ( )} {hosted && ready && !project.archived && ( )}
) })}
)} {/* --- Neues Projekt --- */} {showCreateModal && (
setShowCreateModal(false)}>×

Neues Website-Projekt

{createError && (
{createError}
)}
setCreateForm((p) => ({ ...p, displayName: e.target.value }))} required />
setCreateForm((p) => ({ ...p, subdomain: e.target.value }))} placeholder="leer lassen = Projekt ohne Website" /> Mit Subdomain wird die Website automatisch unter https://<subdomain>.project.webklar.com deployt. Ohne Subdomain wird nur das Gitea-Repo als normales Projekt angelegt.
setCreateForm((p) => ({ ...p, repoName: e.target.value }))} placeholder="leer = aus Anzeigename/Subdomain abgeleitet" />
)} {/* --- Projekt bearbeiten --- */} {editProject && (
setEditProject(null)}>×

Projekt bearbeiten

{editProject.repoFullName}

{editError && (
{editError}
)}
setEditForm((p) => ({ ...p, projectName: e.target.value }))} required />
{TYPES[editForm.projectType].hint}
{editForm.projectType !== 'project' && (
setEditForm((p) => ({ ...p, subdomain: e.target.value }))} placeholder="z.B. musterfirma" /> Ergebnis: https://{slugify(editForm.subdomain) || ''}.project.webklar.com {' - '}Aenderung wird neu deployt (alte Subdomain wird abgebaut).
)}
{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 && (
)}
)}
) } function Row({ label, children }) { return (
{label} {children}
) }