Inline-Projektliste: neue Repos, Zuweisen mit Umzug, Preview einschalten, Pushes nachtragen

Implementiert auf TicketProjects.jsx (Paket-E-Inline-Liste) statt des
frueheren Dialogs, gemaess Controller-Ruling R3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 10:41:35 +00:00
1 parent 56489b8d6d
commit 3e3a9a3bbd
3 files changed
+160 -14

No files matched your search

+87 -1
View File
@@ -1,7 +1,11 @@
import { useState } from 'react'
import { FaCodeBranch } from 'react-icons/fa'
import { FaLinkSlash } from 'react-icons/fa6'
import GiteaLinkButton from './GiteaLinkButton'
import PreviewLinkButton from './PreviewLinkButton'
import { suggestSubdomain } from '../lib/subdomain'
import { needsPreviewButton, assignSummary } from '../lib/projectAssign'
import { updateWebsiteProject, backfillProjectPushes } from '../lib/projectAdminApi'
const STATUS_COLORS = {
ready: '#10b981',
@@ -28,7 +32,49 @@ function StatusBadge({ label, value }) {
)
}
export default function AssignedProjectCard({ project, onUnassign, showActions = false }) {
export default function AssignedProjectCard({ project, onUnassign, showActions = false, customerName = '', onChanged = () => {} }) {
const [showPreviewInput, setShowPreviewInput] = useState(false)
const [subdomain, setSubdomain] = useState('')
const [previewBusy, setPreviewBusy] = useState(false)
const [previewMessage, setPreviewMessage] = useState('')
const [pushBusy, setPushBusy] = useState(false)
const [pushMessage, setPushMessage] = useState('')
const openPreviewInput = () => {
setSubdomain(suggestSubdomain(customerName, project.projectName))
setPreviewMessage('')
setShowPreviewInput(true)
}
const handleEnablePreview = async () => {
setPreviewBusy(true)
setPreviewMessage('')
try {
const r = await updateWebsiteProject(project.$id, { projectType: 'preview', subdomain })
setPreviewMessage(`Preview läuft: ${r.previewUrl}`)
setShowPreviewInput(false)
onChanged()
} catch (err) {
setPreviewMessage(err.message)
} finally {
setPreviewBusy(false)
}
}
const handleBackfill = async () => {
setPushBusy(true)
setPushMessage('')
try {
const r = await backfillProjectPushes(project.$id)
setPushMessage(assignSummary(project.projectName, r).text)
onChanged()
} catch (err) {
setPushMessage(err.message)
} finally {
setPushBusy(false)
}
}
return (
<div style={{ background: 'rgba(30,41,59,0.5)', border: '1px solid rgba(59,130,246,0.25)', borderRadius: 10, padding: '12px 14px', marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', justifyContent: 'space-between' }}>
@@ -63,6 +109,46 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
<StatusBadge label="Status" value={project.status} />
<StatusBadge label="Provisioning" value={project.provisioningStatus} />
</div>
{showActions && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{needsPreviewButton(project) && !showPreviewInput && (
<button type="button" className="btn btn-sm" onClick={openPreviewInput}>
Preview einschalten
</button>
)}
{showPreviewInput && (
<>
<input
type="text"
className="form-control"
style={{ maxWidth: 200 }}
value={subdomain}
onChange={(e) => setSubdomain(e.target.value)}
/>
<button
type="button"
className="btn btn-sm"
disabled={!subdomain || previewBusy}
onClick={handleEnablePreview}
>
Einschalten
</button>
</>
)}
<button type="button" className="btn btn-sm" disabled={pushBusy} onClick={handleBackfill}>
Pushes nachtragen
</button>
</div>
{(previewMessage || pushMessage) && (
<div className="text-grey" style={{ marginTop: 6, fontSize: 12 }}>
{previewMessage && <div>{previewMessage}</div>}
{pushMessage && <div>{pushMessage}</div>}
</div>
)}
</div>
)}
</div>
)
}
+63 -9
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
import { FaPlus, FaLink } from 'react-icons/fa6'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { createProjectFromTemplate } from '../lib/projectAdminApi'
import { createProjectFromTemplate, importNewRepos } from '../lib/projectAdminApi'
import { isOutsideOrg, assignSummary } from '../lib/projectAssign'
import AssignedProjectCard from './AssignedProjectCard'
function slugify(v) {
@@ -31,6 +32,9 @@ export default function TicketProjects({ ticket }) {
const [createForm, setCreateForm] = useState({ displayName: '', subdomain: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [importing, setImporting] = useState(false)
const [importHint, setImportHint] = useState('')
const [assignResult, setAssignResult] = useState(null)
const load = useCallback(async () => {
if (!ticket?.$id) return
@@ -39,7 +43,22 @@ export default function TicketProjects({ ticket }) {
setAvailable(assignableProjects(all, ticket, new Set(mine.map((p) => p.$id))))
}, [ticket, fetchAllProjects, fetchByTicketId])
useEffect(() => { load() }, [load])
useEffect(() => {
let cancelled = false
;(async () => {
setImporting(true)
setImportHint('')
try {
await importNewRepos()
} catch (err) {
if (!cancelled) setImportHint(`Neue Repos konnten nicht geladen werden (${err.message}).`)
} finally {
if (!cancelled) setImporting(false)
}
await load()
})()
return () => { cancelled = true }
}, [load])
const run = async (action) => {
setBusy(true)
@@ -54,11 +73,23 @@ export default function TicketProjects({ ticket }) {
}
}
const assign = () => run(async () => {
const r = await assignProjects([selected], { customerId: ticket.customerId || '', ticketId: ticket.$id })
if (!r.success) throw new Error(r.error)
setSelected('')
})
const assign = async () => {
setBusy(true)
setError('')
setAssignResult(null)
try {
const projectName = available.find((p) => p.$id === selected)?.projectName || ''
const r = await assignProjects([selected], { customerId: ticket.customerId || '', ticketId: ticket.$id })
const one = r.results[0]
setAssignResult(assignSummary(projectName, one.ok ? one.result : { error: one.error }))
if (r.success) setSelected('')
await load()
} catch (err) {
setError(err.message || 'Aktion fehlgeschlagen')
} finally {
setBusy(false)
}
}
const unassign = (projectId) => run(async () => {
const r = await unassignProject(projectId)
@@ -84,22 +115,45 @@ export default function TicketProjects({ ticket }) {
return (
<div className="ui-card pad ticket-projects">
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Projekte ({assigned.length})</h5>
{importing && <p className="muted" style={{ marginTop: 0 }}>Suche neue Repos …</p>}
{importHint && <div className="text-red" style={{ marginBottom: 8 }}>{importHint}</div>}
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
{assigned.length === 0
? <p className="muted" style={{ marginTop: 0 }}>Noch kein Projekt an diesem Ticket.</p>
: assigned.map((p) => <AssignedProjectCard key={p.$id} project={p} showActions onUnassign={unassign} />)}
: assigned.map((p) => (
<AssignedProjectCard
key={p.$id}
project={p}
showActions
onUnassign={unassign}
customerName={ticket.customerName || ''}
onChanged={load}
/>
))}
<div className="ticket-projects-assign">
<select className="form-control" aria-label="Projekt zuweisen" value={selected} onChange={(e) => setSelected(e.target.value)}>
<option value="">Projekt wählen …</option>
{available.map((p) => (
<option key={p.$id} value={p.$id}>{p.projectName} ({p.subdomain || p.repoFullName})</option>
<option key={p.$id} value={p.$id}>
{p.projectName} ({p.subdomain || p.repoFullName})
{isOutsideOrg(p.repoFullName) ? ' – wird beim Zuweisen nach WEBklar verschoben' : ''}
</option>
))}
</select>
<button type="button" className="ui-btn" disabled={!selected || busy} onClick={assign}><FaLink /> Zuweisen</button>
<button type="button" className="ui-btn ui-btn-ghost" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neues Projekt</button>
</div>
{assignResult && (
<div style={{ marginTop: 8, fontSize: 13 }}>
<div>{assignResult.text}</div>
{assignResult.warnings.map((w, i) => (
<div key={i} className="muted" style={{ marginLeft: 12 }}>{w}</div>
))}
</div>
)}
{showCreate && (
<form className="ticket-projects-assign" onSubmit={create} style={{ marginTop: 8 }}>
<input className="form-control" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required />
+10 -4
View File
@@ -23,6 +23,7 @@ import {
archiveWebsiteProject,
unarchiveWebsiteProject,
} from '../lib/projectAdminApi'
import { assignSummary } from '../lib/projectAssign'
import PreviewLinkButton from '../components/PreviewLinkButton'
import GiteaLinkButton from '../components/GiteaLinkButton'
@@ -246,10 +247,15 @@ export default function ProjectsPage() {
const handleAssignCustomer = async (project, customerId) => {
setAssignBusyId(project.$id)
setNotice('')
const r = customerId
? await assignProjects([project.$id], { customerId, ticketId: project.ticketId || '' })
: await unassignProject(project.$id)
if (!r.success) setNotice('Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt'))
if (customerId) {
const r = await assignProjects([project.$id], { customerId, ticketId: project.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 {
const r = await unassignProject(project.$id)
if (!r.success) setNotice('Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt'))
}
await fetchAllProjects()
setAssignBusyId('')
}