Projekte: Liste als Tabelle mit Reitern, Neu- und Bearbeiten-Dialog, Laden nur beim ersten Mal

- Seite mit PageHeader, Reitern (Zaehler aus projectTabs), Filterzeile und Tabelle (Rollen table/row/cell/columnheader, Zeilenklick ueber isRowActivation)
- Neues Projekt und Projekt bearbeiten als Dialog mit Field/htmlFor; Speichern im Fuss ueber das Attribut form
- Ladetext "Wird geladen …" nur beim ersten Laden (loadedOnce), danach bleibt die Tabelle beim Nachladen stehen
- global.css: Ticket-Zelle bricht lange IDs um, .faint in archivierten Zeilen ohne Kontrastverlust (Block Projekte (P5))

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 14:34:29 +00:00
1 parent f1aac2ada9
commit 860079f51d
2 files changed
+334 -469

No files matched your search

+332 -469
View File
@@ -1,18 +1,9 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import PreviewBlock from '../components/PreviewBlock'
import {
FaFolder,
FaPlus,
FaArrowsRotate,
FaPen,
FaBoxArchive,
FaBoxOpen,
FaGlobe,
FaLock,
FaCircleInfo,
FaClone,
} from 'react-icons/fa6'
import PreviewLinkButton from '../components/PreviewLinkButton'
import GiteaLinkButton from '../components/GiteaLinkButton'
import { PageHeader, Tabs, Button, Badge, EmptyState, Field, Dialog } from '../components/ui'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { useCustomers } from '../hooks/useCustomers'
import { useWorkorders } from '../hooks/useWorkorders'
@@ -27,70 +18,22 @@ import {
importNewReposThrottled,
} from '../lib/projectAdminApi'
import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign'
import { projectCounts, projectsInView, viewShowing, matchesSearch, hiddenMatches, VIEW_LABELS } from '../lib/projectList'
import { projectCounts, projectsInView, projectTabs, viewShowing, matchesSearch, hiddenMatches, VIEW_LABELS } from '../lib/projectList'
import { PROJECT_TYPES, typeOf, projectActions, projectTicketRef, updatedLabel } from '../lib/projectRow'
import { ticketPath } from '../lib/ticketRoutes'
import { isRowActivation } from '../lib/rowClick'
import { slugify } from '../lib/subdomain'
import PreviewLinkButton from '../components/PreviewLinkButton'
import GiteaLinkButton from '../components/GiteaLinkButton'
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'
}
// Typen mit Hosting (Subdomain + Deploy)
const HOSTED_TYPES = new Set(['preview', 'website', 'template'])
// Gleiche Logik wie isPreviewProjectReady im Backend (auth.js):
// nur 'ready'/'deployed' gelten als tatsaechlich gehostet.
function isProjectReady(project) {
const st = String(project.provisioningStatus || project.status || '').toLowerCase()
return ['ready', 'deployed'].includes(st)
}
export default function ProjectsPage() {
const navigate = useNavigate()
const { projects, loading, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects()
const { projects, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects()
const { customers } = useCustomers()
const { workorders } = useWorkorders(WORKORDER_FILTERS)
const { employees } = useEmployees()
const { isAdmin } = useAuth()
const [loadedOnce, setLoadedOnce] = useState(false)
const [filter, setFilter] = useState('all')
const [search, setSearch] = useState('')
const [customerFilter, setCustomerFilter] = useState('')
@@ -121,21 +64,13 @@ export default function ProjectsPage() {
// sonst fehlte ein frisch angelegtes Repo hier bis zum naechsten 10-Minuten-Lauf.
useEffect(() => {
let cancelled = false
fetchAllProjects()
fetchAllProjects().then(() => { if (!cancelled) setLoadedOnce(true) })
importNewReposThrottled()
.then((r) => { if (r && !cancelled) fetchAllProjects() })
.catch(() => { /* Import-Fehler zeigt die Ticketkarte; die Liste bleibt wie sie ist */ })
return () => { cancelled = true }
}, [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) {
@@ -355,199 +290,57 @@ export default function ProjectsPage() {
}
}
const FILTER_OPTIONS = [
{ value: 'all', label: `Aktive 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})` },
{ value: 'everything', label: `Alle inkl. Archiv (${counts.everything})` },
]
// Typ-Optionen im Bearbeiten-Dialog: adminOnly-Typen (Vorlage) nur fuer Admins
const typeOptions = Object.entries(PROJECT_TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
// Typ-Optionen im Bearbeiten-Modal: adminOnly-Typen (Vorlage) nur fuer Admins
const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
return (
<div className="page">
<header className="text-center mb-2">
<h2>Website-Projekte</h2>
</header>
<div className="mb-2" style={{ display: 'flex', gap: '10px', justifyContent: 'center', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="search"
className="form-control"
placeholder="Nach Titel suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: '1 1 220px', minWidth: '180px', maxWidth: '320px' }}
/>
<select
className="form-control"
style={{ width: 'auto', minWidth: '170px' }}
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
{FILTER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<select
className="form-control"
style={{ width: 'auto', minWidth: '150px' }}
value={customerFilter}
onChange={(e) => setCustomerFilter(e.target.value)}
title="Nach Kunde filtern"
>
<option value="">Alle Kunden</option>
{sortedCustomers.map((c) => (
<option key={c.$id} value={c.$id}>
{c.name}{c.code ? ` (${c.code})` : ''}
</option>
))}
</select>
<select
className="form-control"
style={{ width: 'auto', minWidth: '150px' }}
value={employeeFilter}
onChange={(e) => setEmployeeFilter(e.target.value)}
title="Nach Mitarbeiter filtern"
>
<option value="">Alle Mitarbeiter</option>
{sortedEmployees.map((emp) => (
<option key={emp.$id} value={emp.userId}>
{emp.displayName || emp.userId}
</option>
))}
</select>
<button
type="button"
className="btn btn-teal"
style={{ padding: '8px 12px' }}
onClick={() => setShowCreateModal(true)}
title="Neues Projekt"
aria-label="Neues Projekt"
>
<FaPlus />
</button>
<button
type="button"
className="btn btn-dark"
style={{ padding: '8px 12px' }}
onClick={handleSyncRepos}
disabled={syncLoading}
title="Gitea-Repos synchronisieren"
aria-label="Gitea-Repos synchronisieren"
>
<FaArrowsRotate style={syncLoading ? { animation: 'spin 1s linear infinite' } : undefined} />
</button>
</div>
{notice && (
<div className="text-center mb-2">
<span className="text-grey">{notice}</span>
</div>
)}
{hiddenHits.length > 0 && (
<div className="text-center mb-2" data-hidden-hits>
<span className="text-grey">Weitere Treffer: </span>
{hiddenHits.map((h, i) => (
<span key={h.view}>
{i > 0 && <span className="text-grey"> · </span>}
<button
type="button"
style={{ padding: 0, border: 'none', background: 'none', color: 'var(--accent)', textDecoration: 'underline', cursor: 'pointer', font: 'inherit' }}
onClick={() => setFilter(h.view)}
>
{h.count} unter „{VIEW_LABELS[h.view]}“
</button>
</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',
}}
>
let content
if (!loadedOnce) {
// Nur beim ersten Laden: spaeter bleibt die Tabelle beim Nachladen stehen
content = <div className="loading">Wird geladen …</div>
} else if (filteredProjects.length === 0) {
content = <EmptyState title={filter === 'archived' ? 'Keine archivierten Projekte.' : 'Keine Projekte gefunden.'} />
} else {
content = (
<table className="table projects-table" role="table">
<thead role="rowgroup">
<tr role="row">
<th role="columnheader">Projekt</th>
<th role="columnheader">Kunde</th>
<th role="columnheader" className="hide-md">Art</th>
<th role="columnheader" className="hide-md">Adresse</th>
<th role="columnheader" className="hide-sm num">Ticket</th>
<th role="columnheader">Status</th>
<th role="columnheader" className="hide-md right">Zuletzt geändert</th>
<th role="columnheader" className="right">Aktionen</th>
</tr>
</thead>
<tbody role="rowgroup">
{filteredProjects.map((project) => {
const type = typeOf(project)
const t = TYPES[type]
const hosted = HOSTED_TYPES.has(type)
const ready = isProjectReady(project)
const needsHosting = hosted && !ready && !project.archived
const actions = projectActions(project)
const archived = Boolean(project.archived)
const type = PROJECT_TYPES[actions.type]
const name = project.projectName || project.giteaRepoName || '(ohne Name)'
const ticketRef = projectTicketRef(project, ticketMap)
const updated = updatedLabel(project.$updatedAt)
return (
<div
<tr
key={project.$id}
className="card"
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px', opacity: project.archived ? 0.72 : 1 }}
role="row"
className={`row${archived ? ' is-archived' : ''}`}
onClick={(e) => { if (isRowActivation(e)) openEdit(project) }}
>
<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>
)}
<td role="cell">
<div className="cell-title">
<button type="button" className="link" onClick={() => openEdit(project)}>{name}</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end', flexShrink: 0 }}>
<span className={`badge ${t.badge}`} style={{ whiteSpace: 'nowrap' }}>
{t.icon} {t.short}
</span>
{hosted && !project.archived && (
<span className={`badge ${ready ? 'badge-ok' : 'badge-warn'}`} style={{ whiteSpace: 'nowrap' }}>
{ready ? 'gehostet' : 'nicht deployt'}
</span>
)}
</div>
</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>
{project.repoFullName && <div className="cell-sub">{project.repoFullName}</div>}
</td>
<td role="cell" data-label="Kunde">
<select
className="form-control"
style={{ fontSize: '13px' }}
className="select"
aria-label={`Kunde von ${name}`}
value={project.customerId || ''}
disabled={assignBusyId === project.$id || project.archived}
disabled={assignBusyId === project.$id || archived}
onChange={(e) => handleAssignCustomer(project, e.target.value)}
>
<option value="">Kein Kunde</option>
@@ -557,234 +350,304 @@ export default function ProjectsPage() {
</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: 'auto' }}>
<button
type="button"
className="btn btn-dark"
style={{ padding: '8px 12px' }}
onClick={() => openEdit(project)}
title="Bearbeiten"
aria-label="Bearbeiten"
>
<FaPen />
</button>
{needsHosting ? (
<button
type="button"
className="btn btn-teal"
style={{ padding: '8px 12px' }}
onClick={() => handleHostNow(project)}
disabled={hostBusyId === project.$id}
title="Projekt jetzt deployen/hosten"
aria-label="Projekt jetzt deployen/hosten"
>
<FaGlobe style={hostBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined} />
</button>
) : (
<PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />
)}
{hosted && ready && !project.archived && (
<button
type="button"
className="btn btn-dark"
style={{ padding: '8px 12px' }}
onClick={() => handleRefresh(project)}
disabled={refreshBusyId === project.$id}
title="Aktualisieren – Website neu deployen"
aria-label="Aktualisieren – Website neu deployen"
>
<FaArrowsRotate
style={refreshBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined}
/>
</button>
)}
<GiteaLinkButton href={project.giteaRepoUrl} />
</div>
</div>
</td>
<td role="cell" className="hide-md">
<Badge tone={type.tone}>{type.short}</Badge>
</td>
<td role="cell" className="hide-md cell-address">
{project.subdomain
? <code title={project.subdomain}>{project.subdomain}</code>
: <span className="muted">ohne Website</span>}
</td>
<td role="cell" className="hide-sm num">
{ticketRef
? <Link className="link" to={ticketPath(ticketRef)}>{ticketRef}</Link>
: <span className="faint">-</span>}
</td>
<td role="cell" data-label="Status">
{actions.hostedBadge && <Badge tone={actions.hostedBadge.tone}>{actions.hostedBadge.text}</Badge>}
<div className="cell-sub">{archived ? 'archiviert' : (project.status || '-')}</div>
</td>
<td role="cell" className="hide-md right cell-updated" title={updated.title || undefined}>
{updated.title ? updated.text : <span className="faint">{updated.text}</span>}
</td>
<td role="cell" className="right cell-actions">
<div className="row-actions">
{actions.host && (
<button
type="button"
className="link"
title="Projekt jetzt deployen/hosten"
onClick={() => handleHostNow(project)}
disabled={hostBusyId === project.$id}
>
{hostBusyId === project.$id ? 'Wird geladen …' : 'Hosten'}
</button>
)}
{actions.preview && <PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />}
{actions.refresh && (
<button
type="button"
className="link"
title="Aktualisieren – Website neu deployen"
onClick={() => handleRefresh(project)}
disabled={refreshBusyId === project.$id}
>
{refreshBusyId === project.$id ? 'Wird geladen …' : 'Aktualisieren'}
</button>
)}
{actions.gitea && <GiteaLinkButton href={project.giteaRepoUrl} />}
</div>
</td>
</tr>
)
})}
</div>
</tbody>
</table>
)
}
return (
<div className="page">
<PageHeader
title="Website-Projekte"
actions={
<>
<Button type="button" onClick={handleSyncRepos} disabled={syncLoading}>
{syncLoading ? 'Wird geladen …' : 'Gitea-Repos synchronisieren'}
</Button>
<Button variant="primary" type="button" onClick={() => setShowCreateModal(true)}>Neues Projekt</Button>
</>
}
/>
<Tabs tabs={projectTabs(counts, { isAdmin })} active={filter} onChange={setFilter} />
<div className="toolbar">
<input
type="search"
className="input search"
aria-label="Nach Titel suchen"
placeholder="Nach Titel suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select
className="select select-quiet"
value={customerFilter}
onChange={(e) => setCustomerFilter(e.target.value)}
title="Nach Kunde filtern"
aria-label="Nach Kunde filtern"
>
<option value="">Alle Kunden</option>
{sortedCustomers.map((c) => (
<option key={c.$id} value={c.$id}>
{c.name}{c.code ? ` (${c.code})` : ''}
</option>
))}
</select>
<select
className="select select-quiet"
value={employeeFilter}
onChange={(e) => setEmployeeFilter(e.target.value)}
title="Nach Mitarbeiter filtern"
aria-label="Nach Mitarbeiter filtern"
>
<option value="">Alle Mitarbeiter</option>
{sortedEmployees.map((emp) => (
<option key={emp.$id} value={emp.userId}>
{emp.displayName || emp.userId}
</option>
))}
</select>
</div>
{notice && <p className="msg muted" role="status">{notice}</p>}
{hiddenHits.length > 0 && (
<p className="msg muted" data-hidden-hits>
Weitere Treffer:{' '}
{hiddenHits.map((h, i) => (
<span key={h.view}>
{i > 0 && ' · '}
<button type="button" className="link" onClick={() => setFilter(h.view)}>
{h.count} unter „{VIEW_LABELS[h.view]}“
</button>
</span>
))}
</p>
)}
{error && <p className="msg err" role="alert">{error}</p>}
{content}
{/* --- 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}
// Repo-Name NICHT hier mitfuellen: nach dem ersten Buchstaben blieb er sonst
// stehen (Repo "m" statt "musterfirma"). Leer = beim Anlegen aus Subdomain/Name.
onChange={(e) => setCreateForm((p) => ({ ...p, subdomain: 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>
<Dialog
as="form"
onSubmit={handleCreate}
title="Neues Website-Projekt"
busy={createLoading}
onClose={() => setShowCreateModal(false)}
footer={
<>
<span className="grow" />
<Button type="submit" variant="primary" disabled={createLoading}>
{createLoading ? 'Wird angelegt...' : 'Projekt anlegen'}
</Button>
</>
}
>
{createError && <p className="msg err" role="alert">{createError}</p>}
<Field label="Anzeigename" htmlFor="projekt-neu-name">
<input
id="projekt-neu-name"
type="text"
className="input"
value={createForm.displayName}
onChange={(e) => setCreateForm((p) => ({ ...p, displayName: e.target.value }))}
required
/>
</Field>
<Field
label="Subdomain (optional)"
htmlFor="projekt-neu-subdomain"
hint="Mit Subdomain wird die Website automatisch unter https://<subdomain>.project.webklar.com deployt. Ohne Subdomain wird nur das Gitea-Repo als normales Projekt angelegt."
>
<input
id="projekt-neu-subdomain"
type="text"
className="input"
value={createForm.subdomain}
// Repo-Name NICHT hier mitfuellen: nach dem ersten Buchstaben blieb er sonst
// stehen (Repo "m" statt "musterfirma"). Leer = beim Anlegen aus Subdomain/Name.
onChange={(e) => setCreateForm((p) => ({ ...p, subdomain: e.target.value }))}
placeholder="leer lassen = Projekt ohne Website"
/>
</Field>
<Field label="Repo-Name (optional)" htmlFor="projekt-neu-repo">
<input
id="projekt-neu-repo"
type="text"
className="input"
value={createForm.repoName}
onChange={(e) => setCreateForm((p) => ({ ...p, repoName: e.target.value }))}
placeholder="leer = aus Anzeigename/Subdomain abgeleitet"
/>
</Field>
<Field label="Kunde (optional)" htmlFor="projekt-neu-kunde">
<select
id="projekt-neu-kunde"
className="select"
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>
</Field>
</Dialog>
)}
{/* --- 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' }}>
<Dialog
wide
title="Projekt bearbeiten"
busy={editLoading || archiveBusy}
onClose={() => setEditProject(null)}
footer={
<>
{editProject.archived ? (
<button
type="button"
className={editProject.archived ? 'btn' : 'link-danger'}
className="link"
onClick={() => handleArchiveToggle(editProject)}
disabled={archiveBusy || editLoading}
>
{editProject.archived
? <><FaBoxOpen /> Reaktivieren</>
: <><FaBoxArchive /> Repository archivieren</>}
Reaktivieren
</button>
<button
type="submit"
className="btn btn-dark"
disabled={editLoading || archiveBusy || editProject.archived}
title={editProject.archived ? 'Archivierte Projekte erst reaktivieren' : undefined}
) : (
<Button
variant="danger"
type="button"
onClick={() => handleArchiveToggle(editProject)}
disabled={archiveBusy || editLoading}
>
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'}
</button>
</div>
{editProject.archived && (
<small className="text-grey" style={{ display: 'block', marginTop: 8 }}>
Archiviert: zum Bearbeiten oder Hosten erst reaktivieren.
</small>
Repository archivieren
</Button>
)}
</form>
{/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */}
{editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && (
<div className="form-group" style={{ marginTop: 16 }}>
<label className="form-label">Preview</label>
<PreviewBlock key={editProject.$id} projectId={editProject.$id} />
</div>
<span className="grow" />
{/* Im Fuss ausserhalb des <form>, verbunden ueber das Attribut form: Enter im Namensfeld speichert, im PreviewBlock nie */}
<Button
type="submit"
form="projekt-bearbeiten"
variant="primary"
disabled={editLoading || archiveBusy || editProject.archived}
title={editProject.archived ? 'Archivierte Projekte erst reaktivieren' : undefined}
>
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'}
</Button>
</>
}
>
{editProject.repoFullName && <p className="muted small">{editProject.repoFullName}</p>}
{editError && <p className="msg err" role="alert">{editError}</p>}
<form id="projekt-bearbeiten" className="dialog-form" onSubmit={handleEditSave}>
<Field label="Name" htmlFor="projekt-bearbeiten-name">
<input
id="projekt-bearbeiten-name"
type="text"
className="input"
value={editForm.projectName}
onChange={(e) => setEditForm((p) => ({ ...p, projectName: e.target.value }))}
required
/>
</Field>
<Field label="Typ / Template" htmlFor="projekt-bearbeiten-typ" hint={PROJECT_TYPES[editForm.projectType].hint}>
<select
id="projekt-bearbeiten-typ"
className="select"
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>
</Field>
{editForm.projectType !== 'project' && (
<Field
label="Subdomain"
htmlFor="projekt-bearbeiten-subdomain"
hint={`Ergebnis: https://${slugify(editForm.subdomain) || '<subdomain>'}.project.webklar.com - Aenderung wird neu deployt (alte Subdomain wird abgebaut).`}
>
<input
id="projekt-bearbeiten-subdomain"
type="text"
className="input"
value={editForm.subdomain}
onChange={(e) => setEditForm((p) => ({ ...p, subdomain: e.target.value }))}
placeholder="z.B. musterfirma"
/>
</Field>
)}
</div>
</div>
</form>
{editProject.archived && <p className="muted small">Archiviert: zum Bearbeiten oder Hosten erst reaktivieren.</p>}
{/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */}
{editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && (
<section>
<h4 className="pv-title">Preview</h4>
<PreviewBlock key={editProject.$id} projectId={editProject.$id} />
</section>
)}
</Dialog>
)}
</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>
)
}