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>
426 lines
18 KiB
React
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
|
|
}
|