Files
tickte-system/src/pages/ProjectsPage.jsx
Claude 9e4c60fdaf feat: Vorlagen-Ansicht (Admin) + Typ Vorlage in Projektseite
- 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>
2026-07-12 09:10:25 +00:00

569 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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://&lt;subdomain&gt;.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>
)
}