- 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>
654 lines
26 KiB
React
654 lines
26 KiB
React
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||
import { Link, useNavigate } from 'react-router-dom'
|
||
import PreviewBlock from '../components/PreviewBlock'
|
||
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'
|
||
import { useEmployees } from '../hooks/useEmployees'
|
||
import { useAuth } from '../context/AuthContext'
|
||
import {
|
||
createProjectFromTemplate,
|
||
syncGiteaRepos,
|
||
updateWebsiteProject,
|
||
archiveWebsiteProject,
|
||
unarchiveWebsiteProject,
|
||
importNewReposThrottled,
|
||
} from '../lib/projectAdminApi'
|
||
import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign'
|
||
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'
|
||
|
||
const WORKORDER_FILTERS = { limit: 500 }
|
||
|
||
export default function ProjectsPage() {
|
||
const navigate = useNavigate()
|
||
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('')
|
||
const [employeeFilter, setEmployeeFilter] = useState('')
|
||
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('')
|
||
const [hostBusyId, setHostBusyId] = useState('')
|
||
const [refreshBusyId, setRefreshBusyId] = 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)
|
||
|
||
// Erst die bekannte Liste zeigen, dann neue/archivierte Gitea-Repos abholen und neu laden -
|
||
// sonst fehlte ein frisch angelegtes Repo hier bis zum naechsten 10-Minuten-Lauf.
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
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 ticketMap = useMemo(() => {
|
||
const map = {}
|
||
for (const wo of workorders) {
|
||
map[wo.$id] = wo.woid || wo.$id
|
||
}
|
||
return map
|
||
}, [workorders])
|
||
|
||
// Ticket-Dokument-ID -> Workorder (fuer Mitarbeiter-Zuordnung ueber assignedTo)
|
||
const workorderById = useMemo(() => {
|
||
const map = {}
|
||
for (const wo of workorders) map[wo.$id] = wo
|
||
return map
|
||
}, [workorders])
|
||
|
||
const sortedCustomers = useMemo(
|
||
() => [...customers].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''))),
|
||
[customers]
|
||
)
|
||
const sortedEmployees = useMemo(
|
||
() => [...employees].sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''))),
|
||
[employees]
|
||
)
|
||
|
||
// Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin)
|
||
const counts = useMemo(() => projectCounts(projects, { isAdmin }), [projects, isAdmin])
|
||
|
||
// Filter neben der Kategorie: Textsuche (Titel/Subdomain/Repo), Kunde, Mitarbeiter (ueber das
|
||
// zugeordnete Ticket -> assignedTo)
|
||
const pageMatches = useCallback((p) => {
|
||
if (!matchesSearch(p, search)) return false
|
||
if (customerFilter && p.customerId !== customerFilter) return false
|
||
if (employeeFilter) {
|
||
const wo = p.ticketId ? workorderById[p.ticketId] : null
|
||
if (!wo || wo.assignedTo !== employeeFilter) return false
|
||
}
|
||
return true
|
||
}, [search, customerFilter, employeeFilter, workorderById])
|
||
|
||
const filteredProjects = useMemo(
|
||
() => projectsInView(projects, filter, { isAdmin }).filter(pageMatches),
|
||
[projects, filter, isAdmin, pageMatches]
|
||
)
|
||
|
||
// Suchtreffer in ausgeblendeten Ansichten (Vorlagen, Archiv) - sonst wirkt ein Projekt verschwunden
|
||
const hiddenHits = useMemo(
|
||
() => (search.trim() ? hiddenMatches(projects, filter, pageMatches, { isAdmin }) : []),
|
||
[projects, filter, isAdmin, search, pageMatches]
|
||
)
|
||
|
||
const handleCreate = async (e) => {
|
||
e.preventDefault()
|
||
setCreateLoading(true)
|
||
setCreateError('')
|
||
try {
|
||
const result = await createProjectFromTemplate({
|
||
repoName: slugify(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()
|
||
// Direkt zur Art-/Kunden-Zuordnung des neuen Repos
|
||
if (result.projectId) navigate(`/projects/zuordnen/${result.projectId}`)
|
||
} catch (err) {
|
||
setCreateError(err.message || 'Projekt konnte nicht angelegt werden')
|
||
// Das Repo kann trotz Fehler entstanden sein - Liste trotzdem neu laden
|
||
fetchAllProjects()
|
||
} 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('')
|
||
if (customerId) {
|
||
const ticketId = ticketIdForCustomer(project, customerId, workorderById)
|
||
const r = await assignProjects([project.$id], { customerId, ticketId })
|
||
const one = r.results[0]
|
||
const summary = assignSummary(project.projectName, one.ok ? one.result : { error: one.error })
|
||
setNotice(summary.warnings.length ? `${summary.text} – ${summary.warnings.join(' – ')}` : summary.text)
|
||
} else if (window.confirm(`Kunde von "${project.projectName}" entfernen? Der Kunde sieht das Projekt dann nicht mehr im Portal, das Ticket wird gelöst.`)) {
|
||
const r = await clearCustomer(project.$id)
|
||
setNotice(r.success ? `"${project.projectName}" hat keinen Kunden mehr.` : 'Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt'))
|
||
}
|
||
await fetchAllProjects()
|
||
setAssignBusyId('')
|
||
}
|
||
|
||
const handleHostNow = async (project) => {
|
||
setHostBusyId(project.$id)
|
||
setNotice('')
|
||
try {
|
||
// Deploy ueber die vorhandene PATCH-Route anstossen (Typ bleibt gleich)
|
||
const r = await updateWebsiteProject(project.$id, { projectType: typeOf(project) })
|
||
setNotice(
|
||
r.deploy?.deployed
|
||
? `"${project.projectName}" wird jetzt gehostet${r.previewUrl ? ': ' + r.previewUrl : ''}`
|
||
: `Deploy angestossen, aber nicht bestaetigt: ${r.deploy?.error || 'siehe Server-Logs'}`
|
||
)
|
||
await fetchAllProjects()
|
||
} catch (err) {
|
||
setNotice('Hosten fehlgeschlagen: ' + (err.message || 'unbekannt'))
|
||
} finally {
|
||
setHostBusyId('')
|
||
}
|
||
}
|
||
|
||
// Bereits gehostetes Projekt erneut deployen (nach Website-Ueberarbeitung neu laden)
|
||
const handleRefresh = async (project) => {
|
||
setRefreshBusyId(project.$id)
|
||
setNotice('')
|
||
try {
|
||
const r = await updateWebsiteProject(project.$id, { projectType: typeOf(project) })
|
||
setNotice(
|
||
r.deploy?.deployed
|
||
? `"${project.projectName}" wurde aktualisiert${r.previewUrl ? ': ' + r.previewUrl : ''}`
|
||
: `Aktualisierung angestossen, aber nicht bestaetigt: ${r.deploy?.error || 'siehe Server-Logs'}`
|
||
)
|
||
await fetchAllProjects()
|
||
} catch (err) {
|
||
setNotice('Aktualisieren fehlgeschlagen: ' + (err.message || 'unbekannt'))
|
||
} finally {
|
||
setRefreshBusyId('')
|
||
}
|
||
}
|
||
|
||
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 projectId = editProject.$id
|
||
const result = await updateWebsiteProject(projectId, payload)
|
||
setEditProject(null)
|
||
let text = result.previewUrl
|
||
? `Projekt aktualisiert - ${result.isPublic ? 'oeffentlich' : 'Preview'}: ${result.previewUrl}`
|
||
: 'Projekt aktualisiert.'
|
||
// Faellt das Projekt aus der aktuellen Ansicht (z. B. zur Vorlage gemacht), dorthin wechseln,
|
||
// wo es jetzt steht - sonst wirkt es verschwunden
|
||
const fresh = (await fetchAllProjects()).find((p) => p.$id === projectId)
|
||
const view = viewShowing(fresh, filter, { isAdmin })
|
||
if (fresh && view !== filter) {
|
||
setFilter(view)
|
||
text += ` – „${fresh.projectName}“ steht jetzt unter „${VIEW_LABELS[view]}“.`
|
||
}
|
||
setNotice(text)
|
||
} 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)
|
||
}
|
||
}
|
||
|
||
// Typ-Optionen im Bearbeiten-Dialog: adminOnly-Typen (Vorlage) nur fuer Admins
|
||
const typeOptions = Object.entries(PROJECT_TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
|
||
|
||
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 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 (
|
||
<tr
|
||
key={project.$id}
|
||
role="row"
|
||
className={`row${archived ? ' is-archived' : ''}`}
|
||
onClick={(e) => { if (isRowActivation(e)) openEdit(project) }}
|
||
>
|
||
<td role="cell">
|
||
<div className="cell-title">
|
||
<button type="button" className="link" onClick={() => openEdit(project)}>{name}</button>
|
||
</div>
|
||
{project.repoFullName && <div className="cell-sub">{project.repoFullName}</div>}
|
||
</td>
|
||
<td role="cell" data-label="Kunde">
|
||
<select
|
||
className="select"
|
||
aria-label={`Kunde von ${name}`}
|
||
value={project.customerId || ''}
|
||
disabled={assignBusyId === project.$id || 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>
|
||
</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>
|
||
)
|
||
})}
|
||
</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 && (
|
||
<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 && (
|
||
<Dialog
|
||
wide
|
||
title="Projekt bearbeiten"
|
||
busy={editLoading || archiveBusy}
|
||
onClose={() => setEditProject(null)}
|
||
footer={
|
||
<>
|
||
{editProject.archived ? (
|
||
<button
|
||
type="button"
|
||
className="link"
|
||
onClick={() => handleArchiveToggle(editProject)}
|
||
disabled={archiveBusy || editLoading}
|
||
>
|
||
Reaktivieren
|
||
</button>
|
||
) : (
|
||
<Button
|
||
variant="danger"
|
||
type="button"
|
||
onClick={() => handleArchiveToggle(editProject)}
|
||
disabled={archiveBusy || editLoading}
|
||
>
|
||
Repository archivieren
|
||
</Button>
|
||
)}
|
||
<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>
|
||
)}
|
||
</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>
|
||
)
|
||
}
|