feat: Website-Projekte als Karten + Bearbeiten-Modal + Archiv-Filter

- Tabelle -> responsives Karten-Grid (kein Excel-Look), Typ-Badges
- Bearbeiten (Stift): Name, Subdomain, Typ (Preview/Oeffentliche Website/Internes Projekt)
  -> PATCH synct Git + Preview + Appwrite
- Repository im Bearbeiten-Modus archivieren/reaktivieren
- Dropdown-Filter Archiviert; archivierte Projekte standardmaessig ausgeblendet

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-12 00:23:45 +00:00
parent 42f729abfc
commit 6c3f03b5b4
2 changed files with 332 additions and 61 deletions

View File

@@ -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(

View File

@@ -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: <FaLock />,
hint: 'Login-geschuetzte Vorschau unter <subdomain>.project.webklar.com',
},
website: {
label: 'Oeffentliche Website',
short: 'Website',
badge: 'badge-ok',
icon: <FaGlobe />,
hint: 'Oeffentlich erreichbar ohne Login unter <subdomain>.project.webklar.com',
},
project: {
label: 'Internes Projekt (kein Hosting)',
short: 'Projekt',
badge: 'badge-muted',
icon: <FaFolder />,
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 (
<div className="main-content">
<header className="text-center mb-2">
@@ -126,13 +257,13 @@ export default function ProjectsPage() {
<div className="text-center mb-2" style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
<select
className="form-control"
style={{ width: 'auto', minWidth: '180px' }}
style={{ width: 'auto', minWidth: '190px' }}
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
<option value="all">Alle Projekte</option>
<option value="unassigned">Unzugeordnet</option>
<option value="assigned">Zugeordnet</option>
{FILTER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<button type="button" className="btn btn-teal" onClick={() => setShowCreateModal(true)}>
<FaPlus /> Neues Projekt
@@ -159,59 +290,95 @@ export default function ProjectsPage() {
) : filteredProjects.length === 0 ? (
<div className="text-center p-4">
<FaFolder size={64} className="text-grey" />
<p className="text-grey mt-2">Keine Projekte gefunden.</p>
<p className="text-grey mt-2">
{filter === 'archived' ? 'Keine archivierten Projekte.' : 'Keine Projekte gefunden.'}
</p>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="table" style={{ width: '100%', fontSize: '14px' }}>
<thead>
<tr>
<th>Name</th>
<th>Subdomain</th>
<th>Template</th>
<th>Kunde</th>
<th>Ticket (WOID)</th>
<th>Status</th>
<th>Links</th>
</tr>
</thead>
<tbody>
{filteredProjects.map((project) => (
<tr key={project.$id}>
<td>{project.projectName}</td>
<td>{project.subdomain || <span className="text-grey">ohne Website</span>}</td>
<td>{project.templateName || '-'}</td>
<td>
<select
className="form-control"
style={{ minWidth: '140px', fontSize: '13px' }}
value={project.customerId || ''}
disabled={assignBusyId === project.$id}
onChange={(e) => handleAssignCustomer(project, e.target.value)}
>
<option value="">Kein Kunde</option>
{customers.map((c) => (
<option key={c.$id} value={c.$id}>
({c.code || ''}) {c.name}
</option>
))}
</select>
</td>
<td>{project.ticketId ? ticketMap[project.ticketId] || project.ticketId : '-'}</td>
<td>{project.status || '-'}</td>
<td>
<div style={{ display: 'flex', gap: 8 }}>
<PreviewLinkButton href={project.previewUrl} />
<GiteaLinkButton href={project.giteaRepoUrl} />
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))',
gap: '16px',
alignItems: 'start',
}}
>
{filteredProjects.map((project) => {
const t = TYPES[typeOf(project)]
return (
<div
key={project.$id}
className="card"
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px', opacity: project.archived ? 0.72 : 1 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: '15px', wordBreak: 'break-word' }}>
{project.projectName || project.giteaRepoName || '(ohne Name)'}
</div>
</td>
</tr>
))}
</tbody>
</table>
{project.repoFullName && (
<div className="text-grey" style={{ fontSize: '12px', marginTop: '2px' }}>
{project.repoFullName}
</div>
)}
</div>
<span className={`badge ${t.badge}`} style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{t.icon} {t.short}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '13px' }}>
<Row label="Subdomain">
{project.subdomain
? <code>{project.subdomain}</code>
: <span className="text-grey">ohne Website</span>}
</Row>
<Row label="Ticket">
{project.ticketId ? (ticketMap[project.ticketId] || project.ticketId) : <span className="text-grey">-</span>}
</Row>
<Row label="Status">
<span className="text-grey">{project.archived ? 'archiviert' : (project.status || '-')}</span>
</Row>
</div>
<div>
<label className="form-label" style={{ fontSize: '12px' }}>Kunde</label>
<select
className="form-control"
style={{ fontSize: '13px' }}
value={project.customerId || ''}
disabled={assignBusyId === project.$id || project.archived}
onChange={(e) => handleAssignCustomer(project, e.target.value)}
>
<option value="">Kein Kunde</option>
{customers.map((c) => (
<option key={c.$id} value={c.$id}>
({c.code || ''}) {c.name}
</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: 'auto' }}>
<button
type="button"
className="btn btn-dark"
style={{ padding: '6px 12px', fontSize: '13px' }}
onClick={() => openEdit(project)}
title="Bearbeiten"
>
<FaPen /> Bearbeiten
</button>
<PreviewLinkButton href={project.previewUrl} />
<GiteaLinkButton href={project.giteaRepoUrl} />
</div>
</div>
)
})}
</div>
)}
{/* --- Neues Projekt --- */}
{showCreateModal && (
<div className="overlay">
<span className="overlay-close" onClick={() => setShowCreateModal(false)}>×</span>
@@ -287,6 +454,95 @@ export default function ProjectsPage() {
</div>
</div>
)}
{/* --- Projekt bearbeiten --- */}
{editProject && (
<div className="overlay">
<span className="overlay-close" onClick={() => setEditProject(null)}>×</span>
<div className="overlay-content">
<h2 className="mb-2">Projekt bearbeiten</h2>
<p className="text-grey mb-2" style={{ fontSize: '13px' }}>
{editProject.repoFullName}
</p>
{editError && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{editError}
</div>
)}
<form onSubmit={handleEditSave}>
<div className="form-group">
<label className="form-label">Name</label>
<input
type="text"
className="form-control"
value={editForm.projectName}
onChange={(e) => setEditForm((p) => ({ ...p, projectName: e.target.value }))}
required
/>
</div>
<div className="form-group">
<label className="form-label">Typ / Template</label>
<select
className="form-control"
value={editForm.projectType}
onChange={(e) => setEditForm((p) => ({ ...p, projectType: e.target.value }))}
>
{Object.entries(TYPES).map(([key, meta]) => (
<option key={key} value={key}>{meta.label}</option>
))}
</select>
<small className="text-grey">
<FaCircleInfo /> {TYPES[editForm.projectType].hint}
</small>
</div>
{editForm.projectType !== 'project' && (
<div className="form-group">
<label className="form-label">Subdomain</label>
<input
type="text"
className="form-control"
value={editForm.subdomain}
onChange={(e) => setEditForm((p) => ({ ...p, subdomain: e.target.value }))}
placeholder="z.B. musterfirma"
/>
<small className="text-grey">
Ergebnis: https://{slugify(editForm.subdomain) || '<subdomain>'}.project.webklar.com
{' - '}Aenderung wird neu deployt (alte Subdomain wird abgebaut).
</small>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', marginTop: '16px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn"
style={{ background: editProject.archived ? 'var(--accent)' : 'var(--danger)', color: '#fff' }}
onClick={() => handleArchiveToggle(editProject)}
disabled={archiveBusy || editLoading}
>
{editProject.archived
? <><FaBoxOpen /> Reaktivieren</>
: <><FaBoxArchive /> Repository archivieren</>}
</button>
<button type="submit" className="btn btn-dark" disabled={editLoading || archiveBusy}>
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
function Row({ label, children }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px' }}>
<span className="text-grey">{label}</span>
<span style={{ textAlign: 'right', wordBreak: 'break-word' }}>{children}</span>
</div>
)
}