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:
1 parent
f1aac2ada9
commit
860079f51d
2 files changed
+334
-469
No files matched your search
+332
-469
@@ -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://<subdomain>.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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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; }
|
||||||
|
|||||||
Reference in new issue
Block a user