import { useState, useEffect, useMemo } from 'react' 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 { useAuth } from '../context/AuthContext' import { createProjectFromTemplate, syncGiteaRepos, updateWebsiteProject, archiveWebsiteProject, unarchiveWebsiteProject, } from '../lib/projectAdminApi' import PreviewLinkButton from '../components/PreviewLinkButton' 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 } // 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' } export default function ProjectsPage() { const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects() const { customers } = useCustomers() const { workorders } = useWorkorders(WORKORDER_FILTERS) const { isAdmin } = useAuth() const [filter, setFilter] = useState('all') 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('') // 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) useEffect(() => { fetchAllProjects() }, [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]) const counts = useMemo(() => { // Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin) 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(() => { if (filter === 'templates') return projects.filter((p) => p.isTemplate && !p.archived) if (filter === 'archived') return projects.filter((p) => p.archived) const active = projects.filter((p) => !p.archived && !p.isTemplate) if (filter === 'unassigned') return active.filter((p) => !p.customerId) if (filter === 'assigned') return active.filter((p) => Boolean(p.customerId)) return active }, [projects, filter]) const handleCreate = async (e) => { e.preventDefault() setCreateLoading(true) setCreateError('') try { const result = await createProjectFromTemplate({ repoName: 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() } catch (err) { setCreateError(err.message || 'Projekt konnte nicht angelegt werden') } 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('') const r = customerId ? await assignProjects([project.$id], { customerId, ticketId: project.ticketId || '' }) : await unassignProject(project.$id) if (!r.success) setNotice('Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt')) await fetchAllProjects() setAssignBusyId('') } 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 result = await updateWebsiteProject(editProject.$id, payload) setEditProject(null) setNotice( result.previewUrl ? `Projekt aktualisiert - ${result.isPublic ? 'oeffentlich' : 'Preview'}: ${result.previewUrl}` : 'Projekt aktualisiert.' ) await fetchAllProjects() } 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: `Alle 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})` }, ] // Typ-Optionen im Bearbeiten-Modal: adminOnly-Typen (Vorlage) nur fuer Admins const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly) return (

Website-Projekte

{notice && (
{notice}
)} {error && (
{error}
)} {loading ? (

Projekte werden geladen...

) : filteredProjects.length === 0 ? (

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

) : (
{filteredProjects.map((project) => { const t = TYPES[typeOf(project)] return (
{project.projectName || project.giteaRepoName || '(ohne Name)'}
{project.repoFullName && (
{project.repoFullName}
)}
{t.icon} {t.short}
{project.subdomain ? {project.subdomain} : ohne Website} {project.ticketId ? (ticketMap[project.ticketId] || project.ticketId) : -} {project.archived ? 'archiviert' : (project.status || '-')}
) })}
)} {/* --- 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, repoName: p.repoName || slugify(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).
)}
)}
) } function Row({ label, children }) { return (
{label} {children}
) }