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 { 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 PreviewBlock from '../components/PreviewBlock'
import { import PreviewLinkButton from '../components/PreviewLinkButton'
FaFolder, import GiteaLinkButton from '../components/GiteaLinkButton'
FaPlus, import { PageHeader, Tabs, Button, Badge, EmptyState, Field, Dialog } from '../components/ui'
FaArrowsRotate,
FaPen,
FaBoxArchive,
FaBoxOpen,
FaGlobe,
FaLock,
FaCircleInfo,
FaClone,
} from 'react-icons/fa6'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects' import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { useCustomers } from '../hooks/useCustomers' import { useCustomers } from '../hooks/useCustomers'
import { useWorkorders } from '../hooks/useWorkorders' import { useWorkorders } from '../hooks/useWorkorders'
@@ -27,70 +18,22 @@ import {
importNewReposThrottled, importNewReposThrottled,
} from '../lib/projectAdminApi' } from '../lib/projectAdminApi'
import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign' 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 { slugify } from '../lib/subdomain'
import PreviewLinkButton from '../components/PreviewLinkButton'
import GiteaLinkButton from '../components/GiteaLinkButton'
const WORKORDER_FILTERS = { limit: 500 } 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() { export default function ProjectsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { projects, loading, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects() const { projects, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects()
const { customers } = useCustomers() const { customers } = useCustomers()
const { workorders } = useWorkorders(WORKORDER_FILTERS) const { workorders } = useWorkorders(WORKORDER_FILTERS)
const { employees } = useEmployees() const { employees } = useEmployees()
const { isAdmin } = useAuth() const { isAdmin } = useAuth()
const [loadedOnce, setLoadedOnce] = useState(false)
const [filter, setFilter] = useState('all') const [filter, setFilter] = useState('all')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [customerFilter, setCustomerFilter] = 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. // sonst fehlte ein frisch angelegtes Repo hier bis zum naechsten 10-Minuten-Lauf.
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
fetchAllProjects() fetchAllProjects().then(() => { if (!cancelled) setLoadedOnce(true) })
importNewReposThrottled() importNewReposThrottled()
.then((r) => { if (r && !cancelled) fetchAllProjects() }) .then((r) => { if (r && !cancelled) fetchAllProjects() })
.catch(() => { /* Import-Fehler zeigt die Ticketkarte; die Liste bleibt wie sie ist */ }) .catch(() => { /* Import-Fehler zeigt die Ticketkarte; die Liste bleibt wie sie ist */ })
return () => { cancelled = true } return () => { cancelled = true }
}, [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 ticketMap = useMemo(() => {
const map = {} const map = {}
for (const wo of workorders) { for (const wo of workorders) {
@@ -355,199 +290,57 @@ export default function ProjectsPage() {
} }
} }
const FILTER_OPTIONS = [ // Typ-Optionen im Bearbeiten-Dialog: adminOnly-Typen (Vorlage) nur fuer Admins
{ value: 'all', label: `Aktive Projekte (${counts.all})` }, const typeOptions = Object.entries(PROJECT_TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
{ 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-Modal: adminOnly-Typen (Vorlage) nur fuer Admins let content
const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly) if (!loadedOnce) {
// Nur beim ersten Laden: spaeter bleibt die Tabelle beim Nachladen stehen
return ( content = <div className="loading">Wird geladen …</div>
<div className="page"> } else if (filteredProjects.length === 0) {
<header className="text-center mb-2"> content = <EmptyState title={filter === 'archived' ? 'Keine archivierten Projekte.' : 'Keine Projekte gefunden.'} />
<h2>Website-Projekte</h2> } else {
</header> content = (
<table className="table projects-table" role="table">
<div className="mb-2" style={{ display: 'flex', gap: '10px', justifyContent: 'center', flexWrap: 'wrap', alignItems: 'center' }}> <thead role="rowgroup">
<input <tr role="row">
type="search" <th role="columnheader">Projekt</th>
className="form-control" <th role="columnheader">Kunde</th>
placeholder="Nach Titel suchen…" <th role="columnheader" className="hide-md">Art</th>
value={search} <th role="columnheader" className="hide-md">Adresse</th>
onChange={(e) => setSearch(e.target.value)} <th role="columnheader" className="hide-sm num">Ticket</th>
style={{ flex: '1 1 220px', minWidth: '180px', maxWidth: '320px' }} <th role="columnheader">Status</th>
/> <th role="columnheader" className="hide-md right">Zuletzt geändert</th>
<select <th role="columnheader" className="right">Aktionen</th>
className="form-control" </tr>
style={{ width: 'auto', minWidth: '170px' }} </thead>
value={filter} <tbody role="rowgroup">
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',
}}
>
{filteredProjects.map((project) => { {filteredProjects.map((project) => {
const type = typeOf(project) const actions = projectActions(project)
const t = TYPES[type] const archived = Boolean(project.archived)
const hosted = HOSTED_TYPES.has(type) const type = PROJECT_TYPES[actions.type]
const ready = isProjectReady(project) const name = project.projectName || project.giteaRepoName || '(ohne Name)'
const needsHosting = hosted && !ready && !project.archived const ticketRef = projectTicketRef(project, ticketMap)
const updated = updatedLabel(project.$updatedAt)
return ( return (
<div <tr
key={project.$id} key={project.$id}
className="card" role="row"
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px', opacity: project.archived ? 0.72 : 1 }} className={`row${archived ? ' is-archived' : ''}`}
onClick={(e) => { if (isRowActivation(e)) openEdit(project) }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}> <td role="cell">
<div style={{ minWidth: 0 }}> <div className="cell-title">
<div style={{ fontWeight: 700, fontSize: '15px', wordBreak: 'break-word' }}> <button type="button" className="link" onClick={() => openEdit(project)}>{name}</button>
{project.projectName || project.giteaRepoName || '(ohne Name)'}
</div>
{project.repoFullName && (
<div className="text-grey" style={{ fontSize: '12px', marginTop: '2px' }}>
{project.repoFullName}
</div>
)}
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end', flexShrink: 0 }}> {project.repoFullName && <div className="cell-sub">{project.repoFullName}</div>}
<span className={`badge ${t.badge}`} style={{ whiteSpace: 'nowrap' }}> </td>
{t.icon} {t.short} <td role="cell" data-label="Kunde">
</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>
<select <select
className="form-control" className="select"
style={{ fontSize: '13px' }} aria-label={`Kunde von ${name}`}
value={project.customerId || ''} value={project.customerId || ''}
disabled={assignBusyId === project.$id || project.archived} disabled={assignBusyId === project.$id || archived}
onChange={(e) => handleAssignCustomer(project, e.target.value)} onChange={(e) => handleAssignCustomer(project, e.target.value)}
> >
<option value="">Kein Kunde</option> <option value="">Kein Kunde</option>
@@ -557,234 +350,304 @@ export default function ProjectsPage() {
</option> </option>
))} ))}
</select> </select>
</div> </td>
<td role="cell" className="hide-md">
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: 'auto' }}> <Badge tone={type.tone}>{type.short}</Badge>
<button </td>
type="button" <td role="cell" className="hide-md cell-address">
className="btn btn-dark" {project.subdomain
style={{ padding: '8px 12px' }} ? <code title={project.subdomain}>{project.subdomain}</code>
onClick={() => openEdit(project)} : <span className="muted">ohne Website</span>}
title="Bearbeiten" </td>
aria-label="Bearbeiten" <td role="cell" className="hide-sm num">
> {ticketRef
<FaPen /> ? <Link className="link" to={ticketPath(ticketRef)}>{ticketRef}</Link>
</button> : <span className="faint">-</span>}
{needsHosting ? ( </td>
<button <td role="cell" data-label="Status">
type="button" {actions.hostedBadge && <Badge tone={actions.hostedBadge.tone}>{actions.hostedBadge.text}</Badge>}
className="btn btn-teal" <div className="cell-sub">{archived ? 'archiviert' : (project.status || '-')}</div>
style={{ padding: '8px 12px' }} </td>
onClick={() => handleHostNow(project)} <td role="cell" className="hide-md right cell-updated" title={updated.title || undefined}>
disabled={hostBusyId === project.$id} {updated.title ? updated.text : <span className="faint">{updated.text}</span>}
title="Projekt jetzt deployen/hosten" </td>
aria-label="Projekt jetzt deployen/hosten" <td role="cell" className="right cell-actions">
> <div className="row-actions">
<FaGlobe style={hostBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined} /> {actions.host && (
</button> <button
) : ( type="button"
<PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} /> className="link"
)} title="Projekt jetzt deployen/hosten"
{hosted && ready && !project.archived && ( onClick={() => handleHostNow(project)}
<button disabled={hostBusyId === project.$id}
type="button" >
className="btn btn-dark" {hostBusyId === project.$id ? 'Wird geladen …' : 'Hosten'}
style={{ padding: '8px 12px' }} </button>
onClick={() => handleRefresh(project)} )}
disabled={refreshBusyId === project.$id} {actions.preview && <PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />}
title="Aktualisieren – Website neu deployen" {actions.refresh && (
aria-label="Aktualisieren – Website neu deployen" <button
> type="button"
<FaArrowsRotate className="link"
style={refreshBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined} title="Aktualisieren – Website neu deployen"
/> onClick={() => handleRefresh(project)}
</button> disabled={refreshBusyId === project.$id}
)} >
<GiteaLinkButton href={project.giteaRepoUrl} /> {refreshBusyId === project.$id ? 'Wird geladen …' : 'Aktualisieren'}
</div> </button>
</div> )}
{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 --- */} {/* --- Neues Projekt --- */}
{showCreateModal && ( {showCreateModal && (
<div className="overlay"> <Dialog
<span className="overlay-close" onClick={() => setShowCreateModal(false)}>×</span> as="form"
<div className="overlay-content"> onSubmit={handleCreate}
<h2 className="mb-2">Neues Website-Projekt</h2> title="Neues Website-Projekt"
{createError && ( busy={createLoading}
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}> onClose={() => setShowCreateModal(false)}
{createError} footer={
</div> <>
)} <span className="grow" />
<form onSubmit={handleCreate}> <Button type="submit" variant="primary" disabled={createLoading}>
<div className="form-group"> {createLoading ? 'Wird angelegt...' : 'Projekt anlegen'}
<label className="form-label">Anzeigename</label> </Button>
<input </>
type="text" }
className="form-control" >
value={createForm.displayName} {createError && <p className="msg err" role="alert">{createError}</p>}
onChange={(e) => setCreateForm((p) => ({ ...p, displayName: e.target.value }))} <Field label="Anzeigename" htmlFor="projekt-neu-name">
required <input
/> id="projekt-neu-name"
</div> type="text"
<div className="form-group"> className="input"
<label className="form-label">Subdomain (optional)</label> value={createForm.displayName}
<input onChange={(e) => setCreateForm((p) => ({ ...p, displayName: e.target.value }))}
type="text" required
className="form-control" />
value={createForm.subdomain} </Field>
// Repo-Name NICHT hier mitfuellen: nach dem ersten Buchstaben blieb er sonst <Field
// stehen (Repo "m" statt "musterfirma"). Leer = beim Anlegen aus Subdomain/Name. label="Subdomain (optional)"
onChange={(e) => setCreateForm((p) => ({ ...p, subdomain: e.target.value }))} htmlFor="projekt-neu-subdomain"
placeholder="leer lassen = Projekt ohne Website" 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."
/> >
<small className="text-grey"> <input
Mit Subdomain wird die Website automatisch unter https://&lt;subdomain&gt;.project.webklar.com deployt. id="projekt-neu-subdomain"
Ohne Subdomain wird nur das Gitea-Repo als normales Projekt angelegt. type="text"
</small> className="input"
</div> value={createForm.subdomain}
<div className="form-group"> // Repo-Name NICHT hier mitfuellen: nach dem ersten Buchstaben blieb er sonst
<label className="form-label">Repo-Name (optional)</label> // stehen (Repo "m" statt "musterfirma"). Leer = beim Anlegen aus Subdomain/Name.
<input onChange={(e) => setCreateForm((p) => ({ ...p, subdomain: e.target.value }))}
type="text" placeholder="leer lassen = Projekt ohne Website"
className="form-control" />
value={createForm.repoName} </Field>
onChange={(e) => setCreateForm((p) => ({ ...p, repoName: e.target.value }))} <Field label="Repo-Name (optional)" htmlFor="projekt-neu-repo">
placeholder="leer = aus Anzeigename/Subdomain abgeleitet" <input
/> id="projekt-neu-repo"
</div> type="text"
<div className="form-group"> className="input"
<label className="form-label">Kunde (optional)</label> value={createForm.repoName}
<select onChange={(e) => setCreateForm((p) => ({ ...p, repoName: e.target.value }))}
className="form-control" placeholder="leer = aus Anzeigename/Subdomain abgeleitet"
value={createForm.customerId} />
onChange={(e) => setCreateForm((p) => ({ ...p, customerId: e.target.value }))} </Field>
> <Field label="Kunde (optional)" htmlFor="projekt-neu-kunde">
<option value="">Kein Kunde</option> <select
{customers.map((c) => ( id="projekt-neu-kunde"
<option key={c.$id} value={c.$id}> className="select"
({c.code || ''}) {c.name} value={createForm.customerId}
</option> onChange={(e) => setCreateForm((p) => ({ ...p, customerId: e.target.value }))}
))} >
</select> <option value="">Kein Kunde</option>
</div> {customers.map((c) => (
<div className="text-center mt-2"> <option key={c.$id} value={c.$id}>
<button type="submit" className="btn btn-dark" disabled={createLoading}> ({c.code || ''}) {c.name}
{createLoading ? 'Wird angelegt...' : 'Projekt anlegen'} </option>
</button> ))}
</div> </select>
</form> </Field>
</div> </Dialog>
</div>
)} )}
{/* --- Projekt bearbeiten --- */} {/* --- Projekt bearbeiten --- */}
{editProject && ( {editProject && (
<div className="overlay"> <Dialog
<span className="overlay-close" onClick={() => setEditProject(null)}>×</span> wide
<div className="overlay-content"> title="Projekt bearbeiten"
<h2 className="mb-2">Projekt bearbeiten</h2> busy={editLoading || archiveBusy}
<p className="text-grey mb-2" style={{ fontSize: '13px' }}> onClose={() => setEditProject(null)}
{editProject.repoFullName} footer={
</p> <>
{editError && ( {editProject.archived ? (
<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 <button
type="button" type="button"
className={editProject.archived ? 'btn' : 'link-danger'} className="link"
onClick={() => handleArchiveToggle(editProject)} onClick={() => handleArchiveToggle(editProject)}
disabled={archiveBusy || editLoading} disabled={archiveBusy || editLoading}
> >
{editProject.archived Reaktivieren
? <><FaBoxOpen /> Reaktivieren</>
: <><FaBoxArchive /> Repository archivieren</>}
</button> </button>
<button ) : (
type="submit" <Button
className="btn btn-dark" variant="danger"
disabled={editLoading || archiveBusy || editProject.archived} type="button"
title={editProject.archived ? 'Archivierte Projekte erst reaktivieren' : undefined} onClick={() => handleArchiveToggle(editProject)}
disabled={archiveBusy || editLoading}
> >
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'} Repository archivieren
</button> </Button>
</div>
{editProject.archived && (
<small className="text-grey" style={{ display: 'block', marginTop: 8 }}>
Archiviert: zum Bearbeiten oder Hosten erst reaktivieren.
</small>
)} )}
</form> <span className="grow" />
{/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */} {/* Im Fuss ausserhalb des <form>, verbunden ueber das Attribut form: Enter im Namensfeld speichert, im PreviewBlock nie */}
{editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && ( <Button
<div className="form-group" style={{ marginTop: 16 }}> type="submit"
<label className="form-label">Preview</label> form="projekt-bearbeiten"
<PreviewBlock key={editProject.$id} projectId={editProject.$id} /> variant="primary"
</div> 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> </form>
</div> {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> </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>
)
}
+2
View File
@@ -1117,10 +1117,12 @@ button.link { text-align: left; } /* Reset und Gewicht von button.link stehen im
.projects-table th, .projects-table td { padding-right: 8px; } .projects-table th, .projects-table td { padding-right: 8px; }
.projects-table th:last-child, .projects-table td:last-child { padding-right: 0; } .projects-table th:last-child, .projects-table td:last-child { padding-right: 0; }
.projects-table .cell-title .link, .projects-table .cell-sub { overflow-wrap: anywhere; } .projects-table .cell-title .link, .projects-table .cell-sub { overflow-wrap: anywhere; }
.projects-table td.num { min-width: 64px; overflow-wrap: anywhere; } /* Ticket ohne Eintrag in der Ticketliste zeigt die lange $id: sie bricht um, die Tabelle waechst nicht ueber den Rahmen; 64 px halten 6-stellige Nummern in einer Zeile */
.projects-table .select { width: clamp(104px, 10vw, 180px); max-width: 100%; } .projects-table .select { width: clamp(104px, 10vw, 180px); max-width: 100%; }
.projects-table td.cell-address code { display: block; max-width: 84px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } .projects-table td.cell-address code { display: block; max-width: 84px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.projects-table td.cell-updated { min-width: 70px; } .projects-table td.cell-updated { min-width: 70px; }
.projects-table tr.is-archived td { opacity: .72; } .projects-table tr.is-archived td { opacity: .72; }
.projects-table tr.is-archived .faint { color: var(--text-2); } /* mit opacity .72 fiele das helle Grau von .faint unter 3:1 (Muster: .table tr.row:hover .faint) */
.row-actions { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; font-size: 13px; } /* Links (a) und Textknoepfe (button) auf einer Zeile; ohne baseline sitzt ein button.link-danger in einer hoeheren Zeile mittig statt oben */ .row-actions { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; font-size: 13px; } /* Links (a) und Textknoepfe (button) auf einer Zeile; ohne baseline sitzt ein button.link-danger in einer hoeheren Zeile mittig statt oben */
.row-actions .link { white-space: nowrap; } .row-actions .link { white-space: nowrap; }
.projects-table .row-actions { justify-content: flex-end; } .projects-table .row-actions { justify-content: flex-end; }