diff --git a/src/lib/projectAdminApi.js b/src/lib/projectAdminApi.js index 7ffd575..4bcd8af 100644 --- a/src/lib/projectAdminApi.js +++ b/src/lib/projectAdminApi.js @@ -29,6 +29,21 @@ export async function syncGiteaRepos() { return adminFetch('/api/admin/gitea/sync-repos', { method: 'POST' }) } +export async function updateWebsiteProject(id, payload) { + return adminFetch(`/api/admin/website-projects/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }) +} + +export async function archiveWebsiteProject(id) { + return adminFetch(`/api/admin/website-projects/${id}/archive`, { method: 'POST' }) +} + +export async function unarchiveWebsiteProject(id) { + return adminFetch(`/api/admin/website-projects/${id}/unarchive`, { method: 'POST' }) +} + export async function fetchProjectReadme(repoFullName) { if (!repoFullName) return { found: false } return adminFetch( diff --git a/src/pages/ProjectsPage.jsx b/src/pages/ProjectsPage.jsx index f4ebee2..97bcd32 100644 --- a/src/pages/ProjectsPage.jsx +++ b/src/pages/ProjectsPage.jsx @@ -1,9 +1,25 @@ import { useState, useEffect, useMemo } from 'react' -import { FaFolder, FaPlus, FaArrowsRotate } from 'react-icons/fa6' +import { + FaFolder, + FaPlus, + FaArrowsRotate, + FaPen, + FaBoxArchive, + FaBoxOpen, + FaGlobe, + FaLock, + FaCircleInfo, +} from 'react-icons/fa6' import { useWebsiteProjects } from '../hooks/useWebsiteProjects' import { useCustomers } from '../hooks/useCustomers' import { useWorkorders } from '../hooks/useWorkorders' -import { createProjectFromTemplate, syncGiteaRepos } from '../lib/projectAdminApi' +import { + createProjectFromTemplate, + syncGiteaRepos, + updateWebsiteProject, + archiveWebsiteProject, + unarchiveWebsiteProject, +} from '../lib/projectAdminApi' import PreviewLinkButton from '../components/PreviewLinkButton' import GiteaLinkButton from '../components/GiteaLinkButton' @@ -17,6 +33,37 @@ function slugify(value) { 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', + }, + project: { + label: 'Internes Projekt (kein Hosting)', + short: 'Projekt', + badge: 'badge-muted', + icon: , + hint: 'Nur Gitea-Repository, keine Website/Subdomain', + }, +} + +function typeOf(project) { + 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() @@ -35,6 +82,13 @@ export default function ProjectsPage() { 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]) @@ -55,14 +109,22 @@ export default function ProjectsPage() { return map }, [workorders]) + const counts = useMemo(() => { + const active = projects.filter((p) => !p.archived) + 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, + } + }, [projects]) + const filteredProjects = useMemo(() => { - if (filter === 'unassigned') { - return projects.filter((p) => !p.customerId) - } - if (filter === 'assigned') { - return projects.filter((p) => Boolean(p.customerId)) - } - return projects + if (filter === 'archived') return projects.filter((p) => p.archived) + const active = projects.filter((p) => !p.archived) + 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) => { @@ -81,7 +143,7 @@ export default function ProjectsPage() { setCreateForm({ displayName: '', subdomain: '', repoName: '', customerId: '' }) setNotice( result.previewUrl - ? `Projekt angelegt – Website: ${result.previewUrl}` + ? `Projekt angelegt - Website: ${result.previewUrl}` : 'Projekt (ohne Website) angelegt.' ) await fetchAllProjects() @@ -117,6 +179,75 @@ export default function ProjectsPage() { 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})` }, + { value: 'archived', label: `Archiviert (${counts.archived})` }, + ] + return (
@@ -126,13 +257,13 @@ export default function ProjectsPage() {
+ +
+ + {project.subdomain + ? {project.subdomain} + : ohne Website} + + + {project.ticketId ? (ticketMap[project.ticketId] || project.ticketId) : -} + + + {project.archived ? 'archiviert' : (project.status || '-')} + +
+ +
+ + +
+ +
+ + + +
+
+ ) + })} )} + {/* --- Neues Projekt --- */} {showCreateModal && (
setShowCreateModal(false)}>× @@ -287,6 +454,95 @@ export default function ProjectsPage() {
)} + + {/* --- 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}
) }