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>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 14:34:29 +00:00
1 parent 47cf82eb91
commit f1aac2ada9
1 file changed
+43 -39
+43 -39
View File
@@ -1,5 +1,4 @@
import { useState, useEffect, useCallback } from 'react' import { Fragment, useState, useEffect, useCallback, useRef } from 'react'
import { FaPlus, FaLink } from 'react-icons/fa6'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects' import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi' import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi'
import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign' import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign'
@@ -7,6 +6,7 @@ import { slugify } from '../lib/subdomain'
import AssignedProjectCard from './AssignedProjectCard' import AssignedProjectCard from './AssignedProjectCard'
import { fetchServices, completeAssignment } from '../lib/projectKindsApi' import { fetchServices, completeAssignment } from '../lib/projectKindsApi'
import { kindLabelFor } from '../lib/assignFlow' import { kindLabelFor } from '../lib/assignFlow'
import { Card, Button, Dialog } from './ui'
// Projekte am Ticket: Liste, Zuweisen, Loesen und neues Projekt an einer Stelle. // 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. // Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt.
@@ -29,12 +29,18 @@ export default function TicketProjects({ ticket }) {
fetchServices().then((s) => setKinds(s.kinds || [])).catch(() => {}) 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 () => { const load = useCallback(async () => {
if (!ticket?.$id) return if (!ticketId) return
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticket.$id)]) const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticketId)])
setAssigned(mine) setAssigned(mine)
setAvailable(assignableProjects(all, ticket, new Set(mine.map((p) => p.$id)))) setAvailable(assignableProjects(all, ticketRef.current, new Set(mine.map((p) => p.$id))))
}, [ticket, fetchAllProjects, fetchByTicketId]) }, [ticketId, fetchAllProjects, fetchByTicketId])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -156,16 +162,15 @@ export default function TicketProjects({ ticket }) {
} }
return ( return (
<div className="ui-card pad ticket-projects"> <Card title={`Projekte (${assigned.length})`}>
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Projekte ({assigned.length})</h5> {importing && <p className="muted">Suche neue Repos …</p>}
{importing && <p className="muted" style={{ marginTop: 0 }}>Suche neue Repos …</p>} {importHint && <p className="msg err" role="alert">{importHint}</p>}
{importHint && <div className="text-red" style={{ marginBottom: 8 }}>{importHint}</div>} {error && <p className="msg err" role="alert">{error}</p>}
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
{assigned.length === 0 {assigned.length === 0
? <p className="muted" style={{ marginTop: 0 }}>Noch kein Projekt an diesem Ticket.</p> ? <p className="muted">Noch kein Projekt an diesem Ticket.</p>
: assigned.map((p) => ( : assigned.map((p) => (
<div key={p.$id}> <div key={p.$id}>
<span className="muted" style={{ fontSize: 12 }} data-testid="project-kind">{kindLabelFor(kinds, p.kind)}</span> <span className="muted xs" data-testid="project-kind">{kindLabelFor(kinds, p.kind)}</span>
<AssignedProjectCard <AssignedProjectCard
project={p} project={p}
showActions showActions
@@ -177,7 +182,7 @@ export default function TicketProjects({ ticket }) {
))} ))}
<div className="ticket-projects-assign"> <div className="ticket-projects-assign">
<select className="form-control" aria-label="Projekt zuweisen" value={selected} onChange={(e) => setSelected(e.target.value)}> <select className="select" aria-label="Projekt zuweisen" value={selected} onChange={(e) => setSelected(e.target.value)}>
<option value="">Projekt wählen …</option> <option value="">Projekt wählen …</option>
{available.map((p) => ( {available.map((p) => (
<option key={p.$id} value={p.$id}> <option key={p.$id} value={p.$id}>
@@ -186,44 +191,43 @@ export default function TicketProjects({ ticket }) {
</option> </option>
))} ))}
</select> </select>
<button type="button" className="ui-btn" disabled={!selected || busy} onClick={assign}><FaLink /> Zuweisen</button> <Button type="button" disabled={!selected || busy} onClick={assign}>Zuweisen</Button>
<button type="button" className="ui-btn ui-btn-ghost" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neues Projekt</button> <Button variant="quiet" type="button" onClick={() => setShowCreate((s) => !s)}>Neues Projekt</Button>
</div> </div>
{assignResult && ( {assignResult && (
<div style={{ marginTop: 8, fontSize: 13 }}> <p className="msg">
<div>{assignResult.text}</div> {assignResult.text}
{assignResult.warnings.map((w, i) => ( {assignResult.warnings.map((w, i) => (
<div key={i} className="muted" style={{ marginLeft: 12 }}>{w}</div> <Fragment key={i}>
<br />
<span className="muted">{w}</span>
</Fragment>
))} ))}
</div> </p>
)} )}
{showCreate && ( {showCreate && (
<form className="ticket-projects-assign" onSubmit={create} style={{ marginTop: 8 }}> <form className="ticket-projects-assign" onSubmit={create}>
<input className="form-control" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required /> <input className="input" aria-label="Anzeigename" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required />
<input className="form-control" placeholder="Subdomain" value={createForm.subdomain} onChange={(e) => setCreateForm((f) => ({ ...f, subdomain: 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" className="ui-btn ui-btn-primary" disabled={busy}>{busy ? 'Wird angelegt …' : 'Anlegen & zuweisen'}</button> <Button type="submit" variant="primary" disabled={busy}>{busy ? 'Wird angelegt …' : 'Anlegen & zuweisen'}</Button>
</form> </form>
)} )}
{kindDialog && ( {kindDialog && (
<div className="overlay"> <Dialog title="Was ist das?" onClose={() => setKindDialog(false)}>
<span className="overlay-close" onClick={() => setKindDialog(false)}>×</span> <p className="muted">Bitte wähle die Art des Projekts, bevor es zugewiesen wird.</p>
<div className="overlay-content"> {kinds.length === 0 && (
<h2 className="mb-2">Was ist das?</h2> <p className="msg err">Arten konnten nicht geladen werden – bitte neu laden.</p>
<p className="muted">Bitte wähle die Art des Projekts, bevor es zugewiesen wird.</p> )}
{kinds.length === 0 && ( <div className="row-buttons">
<div className="text-red" style={{ marginBottom: 8 }}>Arten konnten nicht geladen werden – bitte neu laden.</div> {kinds.map((k) => (
)} <Button key={k.key} type="button" onClick={() => assignWithKind(k.key)}>{k.label}</Button>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> ))}
{kinds.map((k) => (
<button key={k.key} type="button" className="ui-btn" onClick={() => assignWithKind(k.key)}>{k.label}</button>
))}
</div>
</div> </div>
</div> </Dialog>
)} )}
</div> </Card>
) )
} }