Files
tickte-system/src/components/TicketProjects.jsx
T
ClaudeandClaude Sonnet 5.5 f1aac2ada9 Projekte: Projektabschnitt am Ticket als Abschnitt, Art-Frage als Dialog
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>
2026-10-06 14:34:29 +00:00

234 lines
8.9 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 { 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>
)
}