- neuer Typ Vorlage (nur Admin waehlbar), Badge, isTemplate - Dropdown-Filter Vorlagen nur fuer Admins; Vorlagen aus normalen Ansichten ausgeblendet - in Vorlagen-Ansicht: Vorlagen sehen + previewen Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
569 lines
21 KiB
JavaScript
569 lines
21 KiB
JavaScript
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: <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',
|
||
},
|
||
template: {
|
||
label: 'Vorlage',
|
||
short: 'Vorlage',
|
||
badge: 'badge-warn',
|
||
icon: <FaClone />,
|
||
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: <FaFolder />,
|
||
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 (
|
||
<div className="main-content">
|
||
<header className="text-center mb-2">
|
||
<h2>Website-Projekte</h2>
|
||
</header>
|
||
|
||
<div className="text-center mb-2" style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||
<select
|
||
className="form-control"
|
||
style={{ width: 'auto', minWidth: '190px' }}
|
||
value={filter}
|
||
onChange={(e) => setFilter(e.target.value)}
|
||
>
|
||
{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
|
||
</button>
|
||
<button type="button" className="btn btn-dark" onClick={handleSyncRepos} disabled={syncLoading}>
|
||
<FaArrowsRotate /> {syncLoading ? 'Synchronisiere...' : 'Gitea-Repos synchronisieren'}
|
||
</button>
|
||
</div>
|
||
|
||
{notice && (
|
||
<div className="text-center mb-2">
|
||
<span className="text-grey">{notice}</span>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<p className="text-center text-grey">Projekte werden geladen...</p>
|
||
) : filteredProjects.length === 0 ? (
|
||
<div className="text-center p-4">
|
||
<FaFolder size={64} className="text-grey" />
|
||
<p className="text-grey mt-2">
|
||
{filter === 'archived' ? 'Keine archivierten Projekte.' : 'Keine Projekte gefunden.'}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<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>
|
||
{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>
|
||
<div className="overlay-content">
|
||
<h2 className="mb-2">Neues Website-Projekt</h2>
|
||
{createError && (
|
||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||
{createError}
|
||
</div>
|
||
)}
|
||
<form onSubmit={handleCreate}>
|
||
<div className="form-group">
|
||
<label className="form-label">Anzeigename</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
value={createForm.displayName}
|
||
onChange={(e) => setCreateForm((p) => ({ ...p, displayName: e.target.value }))}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Subdomain (optional)</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
value={createForm.subdomain}
|
||
onChange={(e) =>
|
||
setCreateForm((p) => ({
|
||
...p,
|
||
subdomain: e.target.value,
|
||
repoName: p.repoName || slugify(e.target.value),
|
||
}))
|
||
}
|
||
placeholder="leer lassen = Projekt ohne Website"
|
||
/>
|
||
<small className="text-grey">
|
||
Mit Subdomain wird die Website automatisch unter https://<subdomain>.project.webklar.com deployt.
|
||
Ohne Subdomain wird nur das Gitea-Repo als normales Projekt angelegt.
|
||
</small>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Repo-Name (optional)</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
value={createForm.repoName}
|
||
onChange={(e) => setCreateForm((p) => ({ ...p, repoName: e.target.value }))}
|
||
placeholder="leer = aus Anzeigename/Subdomain abgeleitet"
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Kunde (optional)</label>
|
||
<select
|
||
className="form-control"
|
||
value={createForm.customerId}
|
||
onChange={(e) => setCreateForm((p) => ({ ...p, customerId: 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 className="text-center mt-2">
|
||
<button type="submit" className="btn btn-dark" disabled={createLoading}>
|
||
{createLoading ? 'Wird angelegt...' : 'Projekt anlegen'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</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 }))}
|
||
>
|
||
{typeOptions.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>
|
||
)
|
||
}
|