Files
tickte-system/src/pages/ProjectsPage.jsx
T
ClaudeandClaude Sonnet 5.5 860079f51d 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>
2026-10-06 14:34:29 +00:00

654 lines
26 KiB
React
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, 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>
)
}