Files
tickte-system/src/pages/AssignProjectPage.jsx
T
ClaudeandClaude Sonnet 5.5 8b688f9df9 Projekte: Zuordnen-Assistent mit Abschnitten, Brotkrumen und im Text markierter Auswahl
Der Assistent bekommt Brotkrume und Untertitel im Seitenkopf, je Schritt einen Abschnitt, Auswahlzeilen als Knoepfe mit aria-pressed und dem Badge Ausgewaehlt, Felder mit Label-Verknuepfung und keine Icons oder Inline-Stile mehr. Logik, Zustaende und Texte bleiben unveraendert. Vier CSS-Zeilen im Block Projekte (P5) fuer Abstand der Schrittspalte und Kontrast der Kundennummer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:43 +00:00

426 lines
18 KiB
React

import { useState, useEffect, useMemo, useCallback } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useCustomers } from '../hooks/useCustomers'
import { useAuth } from '../context/AuthContext'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { fetchServices, completeAssignment, fetchProject } from '../lib/projectKindsApi'
import { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } from '../lib/assignFlow'
import { suggestSubdomain, slugify } from '../lib/subdomain'
import { isArchived } from '../lib/leads'
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
import { PageHeader, Card, Button, Field, Badge } from '../components/ui'
import { CreateCustomerForm } from './CustomersPage'
const todayIso = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
const fmtDate = (iso) => (iso ? new Date(iso).toLocaleDateString('de-DE') : '')
const STEP_TITLES = {
kind: 'Was ist das?',
'customer?': 'Ist das für einen Kunden?',
customer: 'Welcher Kunde?',
ticket: 'Welches Ticket?',
summary: 'Zusammenfassung',
}
export default function AssignProjectPage() {
const { id } = useParams()
const navigate = useNavigate()
const { isAdmin } = useAuth()
const { customers, createCustomer } = useCustomers()
const [project, setProject] = useState(null)
const [kinds, setKinds] = useState([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState(null)
const [notice, setNotice] = useState(null)
const [done, setDone] = useState(null) // { warnings, path } nach Erfolg mit Warnungen
const [state, setState] = useState({
step: 'kind', kind: '', forCustomer: false, customerId: '', ticketId: '', newTicket: null,
})
const [search, setSearch] = useState('')
const [showNewCustomer, setShowNewCustomer] = useState(false)
const [tickets, setTickets] = useState([])
const [ticketsLoading, setTicketsLoading] = useState(false)
const [ticketsError, setTicketsError] = useState(false)
const [ticketsReload, setTicketsReload] = useState(0)
const [previewOn, setPreviewOn] = useState(true)
const [subdomain, setSubdomain] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const load = useCallback(async () => {
setLoading(true)
setLoadError(null)
try {
const [p, services] = await Promise.all([fetchProject(id), fetchServices()])
setProject(p)
setKinds(services.kinds || [])
} catch (err) {
setLoadError(err.message || 'Projekt konnte nicht geladen werden.')
} finally {
setLoading(false)
}
}, [id])
useEffect(() => { load() }, [load])
const repoName = String(project?.repoFullName || project?.projectName || '').split('/').pop()
const repoUrl = project?.giteaRepoUrl || ''
const kindObj = kinds.find((k) => k.key === state.kind)
const customer = (customers || []).find((c) => c.$id === state.customerId)
// Offene Tickets des Kunden laden, sobald Schritt 4 erreicht ist
useEffect(() => {
if (state.step !== 'ticket' || !state.customerId) return
let cancelled = false
setTicketsLoading(true)
setTicketsError(false)
databases
.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
Query.equal('customerId', state.customerId),
Query.notEqual('status', 'Closed'),
Query.notEqual('status', 'Cancelled'),
Query.orderDesc('$createdAt'),
Query.limit(200),
])
.then((r) => { if (!cancelled) setTickets(r.documents || []) })
.catch(() => { if (!cancelled) { setTickets([]); setTicketsError(true) } })
.finally(() => { if (!cancelled) setTicketsLoading(false) })
return () => { cancelled = true }
}, [state.step, state.customerId, ticketsReload])
const openTickets = useMemo(() => openTicketsFor(tickets, state.customerId), [tickets, state.customerId])
const isWebsiteWithCustomer = state.kind === 'website' && state.forCustomer
// Hat das Projekt schon eine Website (z. B. ueber "Neues Projekt" mit Subdomain angelegt), bleibt
// sie wie sie ist: kein Vorschlag, kein erneutes Einschalten (das benannte sie sonst um und
// stellte eine oeffentliche Website auf Login-Preview).
const hasPreviewAlready = Boolean(project?.subdomain)
useEffect(() => {
if (state.step === 'summary' && isWebsiteWithCustomer && !hasPreviewAlready && !subdomain) {
setSubdomain(suggestSubdomain('', repoName))
}
}, [state.step, isWebsiteWithCustomer, hasPreviewAlready, subdomain, repoName])
const patch = (p) => setState((s) => ({ ...s, ...p }))
const go = (step) => { setError(null); patch({ step }) }
const startNewTicket = () => {
patch({
ticketId: '',
newTicket: newTicketDefaults({
kindLabel: kindObj?.label || '',
ticketType: kindObj?.ticketType || 'Webpage',
repoName,
repoUrl,
customer,
today: todayIso(),
}),
})
}
const setNT = (field) => (e) => setState((s) => ({ ...s, newTicket: { ...s.newTicket, [field]: e.target.value } }))
const stepReady = () => {
switch (state.step) {
case 'kind': return !!state.kind
case 'customer': return !!state.customerId
case 'ticket': return canFinish(state)
default: return false
}
}
const finish = async () => {
setBusy(true)
setError(null)
// R9: nie newTicket.type senden, genau eins von ticketId / newTicket
const body = { kind: state.kind, forCustomer: state.forCustomer }
if (state.forCustomer) {
body.customerId = state.customerId
if (state.ticketId) {
body.ticketId = state.ticketId
} else {
const { topic, requestedBy, startDate, details } = state.newTicket
body.newTicket = { topic, requestedBy, startDate, details }
}
if (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && subdomain.trim()) {
body.preview = { subdomain: slugify(subdomain) }
}
}
try {
const result = await completeAssignment(id, body)
let path = state.forCustomer ? '/projects' : '/services'
if (result.ticketId) {
try {
const t = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, result.ticketId)
path = ticketPath(ticketRef(t))
} catch {
path = '/tickets'
}
}
if (result.warnings?.length) {
setDone({ warnings: result.warnings, path })
setBusy(false)
} else {
navigate(path)
}
} catch (err) {
if (err.status === 409) {
setNotice('Die Art wurde inzwischen von jemand anderem festgelegt.')
await load()
} else {
setError(err.message || 'Abschließen fehlgeschlagen.')
}
setBusy(false)
}
}
if (loading) return <div className="page"><div className="loading">Wird geladen …</div></div>
if (loadError) {
return (
<div className="page">
<PageHeader crumb={[{ label: 'Projekte', to: '/projects' }]} title="Projekt zuordnen" />
<p className="msg err" role="alert">{loadError}</p>
</div>
)
}
const createdLine = project.$createdAt && (
<> · angelegt am {fmtDate(project.$createdAt)}{project.createdByGitea ? ` von ${project.createdByGitea}` : ''}</>
)
const header = (
<PageHeader
crumb={[{ label: 'Projekte', to: '/projects' }, { label: repoName || project.projectName }]}
title="Projekt zuordnen"
subtitle={(repoUrl || createdLine) ? (
<>
{repoUrl && <a className="link" href={repoUrl} target="_blank" rel="noreferrer">Repository in Gitea öffnen</a>}
{createdLine}
</>
) : undefined}
/>
)
if (done) {
return (
<div className="page">
{header}
<Card title="Zugeordnet, mit Hinweisen">
<div className="assign-step">
<ul className="summary">
{done.warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
<div className="row-buttons">
<Button type="button" variant="primary" onClick={() => navigate(done.path)}>Weiter</Button>
</div>
</div>
</Card>
</div>
)
}
// Art bereits gesetzt: nur Hinweis ("Art ändern" bewusst nicht Teil dieser Aufgabe)
if (project.kind) {
return (
<div className="page">
{header}
<Card>
<div className="assign-step">
{notice && <p className="msg err" role="alert">{notice}</p>}
<p>
Bereits erledigt von <strong>{project.kindSetBy || 'unbekannt'}</strong> am {fmtDate(project.kindSetAt)}.
</p>
{project.ticketId && <p><TicketLink ticketId={project.ticketId} /></p>}
</div>
</Card>
</div>
)
}
const filteredCustomers = (customers || [])
.filter((c) => !isArchived(c))
.filter((c) => !search || `${c.name} ${c.code || ''} ${c.location || ''}`.toLowerCase().includes(search.toLowerCase()))
return (
<div className="page">
{header}
<Card title={STEP_TITLES[state.step]}>
<div className="assign-step">
{state.step === 'kind' && (
<div className="seg wrap" role="group" aria-label={STEP_TITLES.kind}>
{kinds.map((k) => (
<button
key={k.key}
type="button"
aria-pressed={state.kind === k.key}
onClick={() => patch({ kind: k.key, newTicket: null, ticketId: '' })}
>
{k.label}
</button>
))}
</div>
)}
{state.step === 'customer?' && (
<div className="row-buttons">
<Button type="button" variant="primary"
onClick={() => { patch({ forCustomer: true }); go('customer') }}>Ja, für einen Kunden</Button>
<Button type="button"
onClick={() => { patch({ forCustomer: false, customerId: '', ticketId: '', newTicket: null }); go('summary') }}>Nein, internes Projekt</Button>
</div>
)}
{state.step === 'customer' && (
<>
<div className="row-buttons">
<input className="input search" aria-label="Kunde suchen" placeholder="Kunde suchen …" value={search}
onChange={(e) => setSearch(e.target.value)} />
{isAdmin && (
<Button type="button" onClick={() => setShowNewCustomer((s) => !s)}>Neuer Kunde</Button>
)}
</div>
{showNewCustomer && isAdmin && (
<CreateCustomerForm
onCancel={() => setShowNewCustomer(false)}
onCreate={async (data) => {
const r = await createCustomer(data)
if (r.success) {
setShowNewCustomer(false)
patch({ customerId: r.data.$id, ticketId: '', newTicket: null })
}
return r
}}
/>
)}
<div className="pick-list">
{filteredCustomers.slice(0, 100).map((c) => {
const picked = state.customerId === c.$id
return (
<button key={c.$id} type="button" className="list-row" aria-pressed={picked}
onClick={() => patch({ customerId: c.$id, ticketId: '', newTicket: null })}>
<span className="list-row-text">
<span className="cell-title">{c.name}{c.code && <span className="faint"> · {c.code}</span>}</span>
<span className="cell-sub">{[c.location, c.email].filter(Boolean).join(' · ') || '-'}</span>
</span>
{picked && <Badge>Ausgewählt</Badge>}
</button>
)
})}
{filteredCustomers.length === 0 && <p className="muted pick-empty">Kein Kunde gefunden.</p>}
</div>
</>
)}
{state.step === 'ticket' && (
<>
{ticketsLoading ? <p className="muted">Wird geladen …</p> : ticketsError ? (
<>
<p className="msg err" role="alert">Die Tickets des Kunden konnten nicht geladen werden.</p>
<div className="row-buttons">
<Button type="button" onClick={() => setTicketsReload((n) => n + 1)}>Erneut versuchen</Button>
</div>
</>
) : (
<div className="pick-list">
{openTickets.map((t) => {
const picked = state.ticketId === t.$id
return (
<button key={t.$id} type="button" className="list-row" aria-pressed={picked}
onClick={() => patch({ ticketId: t.$id, newTicket: null })}>
<span className="list-row-text">
<span className="cell-title">#{t.woid} {t.topic}</span>
<span className="cell-sub">{[t.type, t.status].filter(Boolean).join(' · ')}</span>
</span>
{picked && <Badge>Ausgewählt</Badge>}
</button>
)
})}
{openTickets.length === 0 && <p className="muted pick-empty">Dieser Kunde hat keine offenen Tickets.</p>}
</div>
)}
{!ticketsError && !ticketsLoading && (
<div className="row-buttons">
<Button type="button" variant={state.newTicket ? 'primary' : 'default'} onClick={startNewTicket}>Neues Ticket anlegen</Button>
</div>
)}
{state.newTicket && (
<>
<div className="field-row-3">
<Field label="Thema" htmlFor="assign-topic"><input id="assign-topic" className="input" value={state.newTicket.topic} onChange={setNT('topic')} /></Field>
<Field label="Angefragt von" htmlFor="assign-requested-by"><input id="assign-requested-by" className="input" value={state.newTicket.requestedBy} onChange={setNT('requestedBy')} /></Field>
<Field label="Start" htmlFor="assign-start"><input id="assign-start" className="input" type="date" value={state.newTicket.startDate} onChange={setNT('startDate')} /></Field>
</div>
<Field label="Details" htmlFor="assign-details"><textarea id="assign-details" className="textarea" rows={3} value={state.newTicket.details} onChange={setNT('details')} /></Field>
<p className="muted small">Ticket-Art: {kindObj?.ticketType === 'Migration' ? 'Migration' : 'Webpage'} (ergibt sich aus der Art)</p>
</>
)}
</>
)}
{state.step === 'summary' && (
<>
<ul className="summary">
<li>Art: <strong>{kindObj?.label}</strong></li>
<li>{state.forCustomer ? <>Kunde: <strong>{customer?.name}</strong></> : 'Internes Projekt (ohne Kunde)'}</li>
{state.forCustomer && (
<li>
{state.ticketId
? <>Ticket: <strong>{openTickets.find((t) => t.$id === state.ticketId)?.topic || 'vorhandenes Ticket'}</strong></>
: <>Neues Ticket: <strong>{state.newTicket?.topic}</strong></>}
</li>
)}
</ul>
{isWebsiteWithCustomer && hasPreviewAlready && (
<p className="muted">
{['deployed', 'ready'].includes(String(project.status || '').toLowerCase())
? <>Preview läuft bereits unter https://{project.subdomain}.project.webklar.com und bleibt unverändert.</>
: <>Preview ist unter https://{project.subdomain}.project.webklar.com eingerichtet (Status: {project.status || 'unbekannt'}). Neu bauen geht im Projekt über „Preview".</>}
</p>
)}
{isWebsiteWithCustomer && !hasPreviewAlready && (
<>
<label className="check">
<input type="checkbox" checked={previewOn} onChange={(e) => setPreviewOn(e.target.checked)} />
Preview einschalten
</label>
{previewOn && (
<Field label="Subdomain" htmlFor="assign-subdomain" hint={`Ergebnis: https://${slugify(subdomain) || '<subdomain>'}.project.webklar.com`}>
<input id="assign-subdomain" className="input" value={subdomain} onChange={(e) => setSubdomain(e.target.value)} />
</Field>
)}
</>
)}
<div className="row-buttons">
<Button type="button" variant="primary" disabled={busy || !canFinish(state) || (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && !slugify(subdomain))} onClick={finish}>
{busy ? 'Speichert …' : 'Abschließen'}
</Button>
</div>
</>
)}
{error && <p className="msg err" role="alert">{error}</p>}
<div className="row-buttons">
{state.step !== 'kind' && (
<Button type="button" variant="ghost" disabled={busy} onClick={() => go(prevStep(state))}>Zurück</Button>
)}
{(state.step === 'kind' || state.step === 'customer' || state.step === 'ticket') && (
<Button type="button" variant="primary" disabled={!stepReady()} onClick={() => go(nextStep(state))}>Weiter</Button>
)}
</div>
</div>
</Card>
</div>
)
}
function TicketLink({ ticketId }) {
const [woid, setWoid] = useState(null)
useEffect(() => {
databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticketId)
.then((t) => setWoid(t.woid)).catch(() => setWoid(null))
}, [ticketId])
return woid ? <Link className="link" to={ticketPath(woid)}>Zum Ticket #{woid}</Link> : null
}