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:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user