Der Abschnitt nutzt Card, Button und Dialog statt Altklassen und Icons. TicketProjects laedt nur noch bei einer anderen Ticket-ID neu (load haengt an ticket.$id, der neueste Ticketstand kommt ueber ein ref), damit "Suche neue Repos ..." bei Status-, Zustaendig- oder Dringend-Aenderung nicht erneut blinkt. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
234 lines
8.9 KiB
React
234 lines
8.9 KiB
React
import { Fragment, useState, useEffect, useCallback, useRef } from 'react'
|
||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||
import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi'
|
||
import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign'
|
||
import { slugify } from '../lib/subdomain'
|
||
import AssignedProjectCard from './AssignedProjectCard'
|
||
import { fetchServices, completeAssignment } from '../lib/projectKindsApi'
|
||
import { kindLabelFor } from '../lib/assignFlow'
|
||
import { Card, Button, Dialog } from './ui'
|
||
|
||
// Projekte am Ticket: Liste, Zuweisen, Loesen und neues Projekt an einer Stelle.
|
||
// Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt.
|
||
export default function TicketProjects({ ticket }) {
|
||
const { fetchAllProjects, fetchByTicketId, assignProjects, unassignProject } = useWebsiteProjects()
|
||
const [assigned, setAssigned] = useState([])
|
||
const [available, setAvailable] = useState([])
|
||
const [selected, setSelected] = useState('')
|
||
const [showCreate, setShowCreate] = useState(false)
|
||
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 [kinds, setKinds] = useState([])
|
||
const [kindDialog, setKindDialog] = useState(false)
|
||
|
||
useEffect(() => {
|
||
fetchServices().then((s) => setKinds(s.kinds || [])).catch(() => {})
|
||
}, [])
|
||
|
||
// Neu laden (samt "Suche neue Repos ...") nur bei einem anderen Ticket, nicht bei Status, Zustaendig oder Dringend:
|
||
// load haengt an der Ticket-ID, den jeweils neuesten Ticketstand (Kunde) liest es ueber ticketRef
|
||
const ticketRef = useRef(ticket)
|
||
ticketRef.current = ticket
|
||
const ticketId = ticket?.$id
|
||
|
||
const load = useCallback(async () => {
|
||
if (!ticketId) return
|
||
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticketId)])
|
||
setAssigned(mine)
|
||
setAvailable(assignableProjects(all, ticketRef.current, new Set(mine.map((p) => p.$id))))
|
||
}, [ticketId, fetchAllProjects, fetchByTicketId])
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
;(async () => {
|
||
setImporting(true)
|
||
setImportHint('')
|
||
try {
|
||
await importNewReposThrottled()
|
||
} catch (err) {
|
||
if (!cancelled) setImportHint(`Neue Repos konnten nicht geladen werden (${err.message}).`)
|
||
} finally {
|
||
if (!cancelled) setImporting(false)
|
||
}
|
||
if (!cancelled) await load()
|
||
})()
|
||
return () => { cancelled = true }
|
||
}, [load])
|
||
|
||
const run = async (action) => {
|
||
setBusy(true)
|
||
setError('')
|
||
try {
|
||
await action()
|
||
} catch (err) {
|
||
setError(err.message || 'Aktion fehlgeschlagen')
|
||
} finally {
|
||
// Auch nach einem Fehler neu laden: das Repo kann trotzdem entstanden sein
|
||
try { await load() } catch { /* Anzeige bleibt wie sie ist */ }
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const assign = () => {
|
||
const project = available.find((p) => p.$id === selected)
|
||
// Ohne Art erst "Was ist das?" fragen
|
||
if (project && !project.kind) {
|
||
setKindDialog(true)
|
||
return
|
||
}
|
||
assignPlain()
|
||
}
|
||
|
||
const assignPlain = 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)
|
||
}
|
||
}
|
||
|
||
// Art gewaehlt: completeAssignment weist serverseitig (wie assignProject) zu,
|
||
// daher kein zusaetzliches assignProjects.
|
||
// Bei 409 (bereits zugeordnet) bewusst NICHT auf assignProjects zurueckfallen:
|
||
// das wuerde das Projekt ohne Art zuweisen. Stattdessen Meldung zeigen und neu laden.
|
||
const assignWithKind = async (kind) => {
|
||
setKindDialog(false)
|
||
setError('')
|
||
setAssignResult(null)
|
||
if (!ticket.customerId) {
|
||
setError('Dieses Ticket hat keinen Kunden. Bitte zuerst einen Kunden am Ticket eintragen.')
|
||
return
|
||
}
|
||
setBusy(true)
|
||
try {
|
||
const projectName = available.find((p) => p.$id === selected)?.projectName || ''
|
||
const r = await completeAssignment(selected, {
|
||
kind,
|
||
forCustomer: true,
|
||
customerId: ticket.customerId,
|
||
ticketId: ticket.$id,
|
||
})
|
||
setAssignResult({ text: `${projectName || 'Projekt'} wurde zugewiesen.`, warnings: r.warnings || [] })
|
||
setSelected('')
|
||
} catch (err) {
|
||
setError(err.status === 409
|
||
? `${err.message} Die Liste wurde neu geladen.`
|
||
: err.message || 'Aktion fehlgeschlagen')
|
||
} finally {
|
||
try { await load() } catch { /* Anzeige bleibt wie sie ist */ }
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const unassign = (projectId) => run(async () => {
|
||
const r = await unassignProject(projectId)
|
||
if (!r.success) throw new Error(r.error)
|
||
})
|
||
|
||
const create = (e) => {
|
||
e.preventDefault()
|
||
if (!slugify(createForm.subdomain)) {
|
||
setError('Bitte eine gültige Subdomain eingeben (Buchstaben, Ziffern, Bindestrich).')
|
||
return
|
||
}
|
||
run(async () => {
|
||
const subdomain = slugify(createForm.subdomain)
|
||
await createProjectFromTemplate({
|
||
repoName: subdomain,
|
||
displayName: createForm.displayName,
|
||
subdomain,
|
||
customerId: ticket.customerId || '',
|
||
ticketId: ticket.$id,
|
||
// Aus der Website-Vorlage mit Subdomain: die Art steht fest (sonst "Art fehlt")
|
||
kind: 'website',
|
||
})
|
||
setCreateForm({ displayName: '', subdomain: '' })
|
||
setShowCreate(false)
|
||
})
|
||
}
|
||
|
||
return (
|
||
<Card title={`Projekte (${assigned.length})`}>
|
||
{importing && <p className="muted">Suche neue Repos …</p>}
|
||
{importHint && <p className="msg err" role="alert">{importHint}</p>}
|
||
{error && <p className="msg err" role="alert">{error}</p>}
|
||
{assigned.length === 0
|
||
? <p className="muted">Noch kein Projekt an diesem Ticket.</p>
|
||
: assigned.map((p) => (
|
||
<div key={p.$id}>
|
||
<span className="muted xs" data-testid="project-kind">{kindLabelFor(kinds, p.kind)}</span>
|
||
<AssignedProjectCard
|
||
project={p}
|
||
showActions
|
||
onUnassign={unassign}
|
||
customerName={ticket.customerName || ''}
|
||
onChanged={load}
|
||
/>
|
||
</div>
|
||
))}
|
||
|
||
<div className="ticket-projects-assign">
|
||
<select className="select" 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})
|
||
{isOutsideOrg(p.repoFullName) ? ' – wird beim Zuweisen nach WEBklar verschoben' : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<Button type="button" disabled={!selected || busy} onClick={assign}>Zuweisen</Button>
|
||
<Button variant="quiet" type="button" onClick={() => setShowCreate((s) => !s)}>Neues Projekt</Button>
|
||
</div>
|
||
|
||
{assignResult && (
|
||
<p className="msg">
|
||
{assignResult.text}
|
||
{assignResult.warnings.map((w, i) => (
|
||
<Fragment key={i}>
|
||
<br />
|
||
<span className="muted">{w}</span>
|
||
</Fragment>
|
||
))}
|
||
</p>
|
||
)}
|
||
|
||
{showCreate && (
|
||
<form className="ticket-projects-assign" onSubmit={create}>
|
||
<input className="input" aria-label="Anzeigename" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required />
|
||
<input className="input" aria-label="Subdomain" placeholder="Subdomain" value={createForm.subdomain} onChange={(e) => setCreateForm((f) => ({ ...f, subdomain: e.target.value }))} required />
|
||
<Button type="submit" variant="primary" disabled={busy}>{busy ? 'Wird angelegt …' : 'Anlegen & zuweisen'}</Button>
|
||
</form>
|
||
)}
|
||
|
||
{kindDialog && (
|
||
<Dialog title="Was ist das?" onClose={() => setKindDialog(false)}>
|
||
<p className="muted">Bitte wähle die Art des Projekts, bevor es zugewiesen wird.</p>
|
||
{kinds.length === 0 && (
|
||
<p className="msg err">Arten konnten nicht geladen werden – bitte neu laden.</p>
|
||
)}
|
||
<div className="row-buttons">
|
||
{kinds.map((k) => (
|
||
<Button key={k.key} type="button" onClick={() => assignWithKind(k.key)}>{k.label}</Button>
|
||
))}
|
||
</div>
|
||
</Dialog>
|
||
)}
|
||
</Card>
|
||
)
|
||
}
|