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>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 14:34:43 +00:00
1 parent 860079f51d
commit 8b688f9df9
2 files changed
+108 -83

No files matched your search

+103 -83
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { FaSpinner, FaPlus, FaArrowLeft, FaArrowRight, FaCheck } from 'react-icons/fa6'
import { useCustomers } from '../hooks/useCustomers'
import { useAuth } from '../context/AuthContext'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
@@ -9,7 +8,7 @@ import { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } from
import { suggestSubdomain, slugify } from '../lib/subdomain'
import { isArchived } from '../lib/leads'
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
import { PageHeader, Card, Button, Field } from '../components/ui'
import { PageHeader, Card, Button, Field, Badge } from '../components/ui'
import { CreateCustomerForm } from './CustomersPage'
const todayIso = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
@@ -176,36 +175,45 @@ export default function AssignProjectPage() {
}
}
if (loading) return <div className="page"><div className="empty"><FaSpinner className="spinner" /></div></div>
if (loading) return <div className="page"><div className="loading">Wird geladen …</div></div>
if (loadError) {
return (
<div className="page">
<PageHeader title="Projekt zuordnen" />
<Card><div className="text-red">{loadError}</div></Card>
<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 = (
<Card>
<div style={{ fontWeight: 700, fontSize: 18 }}>{repoName || project.projectName}</div>
<div className="muted" style={{ marginTop: 4 }}>
{repoUrl && <a href={repoUrl} target="_blank" rel="noreferrer">Repository in Gitea öffnen</a>}
{project.$createdAt && <> · angelegt am {fmtDate(project.$createdAt)}{project.createdByGitea ? ` von ${project.createdByGitea}` : ''}</>}
</div>
</Card>
<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">
<PageHeader title="Projekt zuordnen" />
{header}
<Card title="Zugeordnet, mit Hinweisen">
<ul style={{ marginTop: 0, paddingLeft: 18 }}>
<div className="assign-step">
<ul className="summary">
{done.warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
<Button variant="primary" onClick={() => navigate(done.path)}>Weiter <FaArrowRight /></Button>
<div className="row-buttons">
<Button type="button" variant="primary" onClick={() => navigate(done.path)}>Weiter</Button>
</div>
</div>
</Card>
</div>
)
@@ -215,16 +223,15 @@ export default function AssignProjectPage() {
if (project.kind) {
return (
<div className="page">
<PageHeader title="Projekt zuordnen" />
{header}
<Card>
{notice && <div className="text-red" style={{ marginBottom: 8 }}>{notice}</div>}
<p style={{ marginTop: 0 }}>
<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 style={{ marginBottom: 0 }}><TicketLink ticketId={project.ticketId} /></p>
)}
{project.ticketId && <p><TicketLink ticketId={project.ticketId} /></p>}
</div>
</Card>
</div>
)
@@ -236,41 +243,41 @@ export default function AssignProjectPage() {
return (
<div className="page">
<PageHeader title="Projekt zuordnen" subtitle={STEP_TITLES[state.step]} />
{header}
<Card title={STEP_TITLES[state.step]}>
<div className="assign-step">
{state.step === 'kind' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 10 }}>
<div className="seg wrap" role="group" aria-label={STEP_TITLES.kind}>
{kinds.map((k) => (
<Button
<button
key={k.key}
variant={state.kind === k.key ? 'primary' : 'default'}
style={{ padding: '18px 12px', fontSize: 16, fontWeight: 700 }}
type="button"
aria-pressed={state.kind === k.key}
onClick={() => patch({ kind: k.key, newTicket: null, ticketId: '' })}
>
{k.label}
</Button>
</button>
))}
</div>
)}
{state.step === 'customer?' && (
<div className="flex gap-2 wrap">
<Button variant="primary" style={{ padding: '14px 28px' }}
<div className="row-buttons">
<Button type="button" variant="primary"
onClick={() => { patch({ forCustomer: true }); go('customer') }}>Ja, für einen Kunden</Button>
<Button style={{ padding: '14px 28px' }}
<Button type="button"
onClick={() => { patch({ forCustomer: false, customerId: '', ticketId: '', newTicket: null }); go('summary') }}>Nein, internes Projekt</Button>
</div>
)}
{state.step === 'customer' && (
<>
<div className="flex gap-2 wrap" style={{ marginBottom: 10 }}>
<input className="form-control search-input" placeholder="Kunde suchen …" value={search}
onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 320 }} />
<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 onClick={() => setShowNewCustomer((s) => !s)}><FaPlus /> Neuer Kunde</Button>
<Button type="button" onClick={() => setShowNewCustomer((s) => !s)}>Neuer Kunde</Button>
)}
</div>
{showNewCustomer && isAdmin && (
@@ -286,64 +293,74 @@ export default function AssignProjectPage() {
}}
/>
)}
<div className="list" style={{ maxHeight: 360, overflowY: 'auto' }}>
{filteredCustomers.slice(0, 100).map((c) => (
<div key={c.$id} className="list-row" style={{ cursor: 'pointer', background: state.customerId === c.$id ? 'var(--surface-2)' : undefined }}
<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 })}>
<div className="grow">
<div style={{ fontWeight: 700 }}>{c.name}{c.code && <span className="faint" style={{ fontWeight: 400 }}> · {c.code}</span>}</div>
<div className="muted">{[c.location, c.email].filter(Boolean).join(' · ') || '-'}</div>
</div>
{state.customerId === c.$id && <FaCheck />}
</div>
))}
{filteredCustomers.length === 0 && <div className="muted" style={{ padding: 12 }}>Kein Kunde gefunden.</div>}
<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 ? <FaSpinner className="spinner" /> : ticketsError ? (
<div style={{ marginBottom: 12 }}>
<div className="text-red" style={{ marginBottom: 8 }}>Die Tickets des Kunden konnten nicht geladen werden.</div>
<Button onClick={() => setTicketsReload((n) => n + 1)}>Erneut versuchen</Button>
{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="list" style={{ marginBottom: 12 }}>
{openTickets.map((t) => (
<div key={t.$id} className="list-row" style={{ cursor: 'pointer', background: state.ticketId === t.$id ? 'var(--surface-2)' : undefined }}
<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 })}>
<div className="grow">
<div style={{ fontWeight: 700 }}>#{t.woid} {t.topic}</div>
<div className="muted">{[t.type, t.status].filter(Boolean).join(' · ')}</div>
</div>
{state.ticketId === t.$id && <FaCheck />}
</div>
))}
{openTickets.length === 0 && <div className="muted" style={{ padding: 12 }}>Dieser Kunde hat keine offenen Tickets.</div>}
<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 && (
<Button variant={state.newTicket ? 'primary' : 'default'} onClick={startNewTicket}><FaPlus /> Neues Ticket anlegen</Button>
<div className="row-buttons">
<Button type="button" variant={state.newTicket ? 'primary' : 'default'} onClick={startNewTicket}>Neues Ticket anlegen</Button>
</div>
)}
{state.newTicket && (
<div style={{ marginTop: 12 }}>
<div className="flex gap-3 wrap">
<Field label="Thema"><input className="form-control" value={state.newTicket.topic} onChange={setNT('topic')} /></Field>
<Field label="Angefragt von"><input className="form-control" value={state.newTicket.requestedBy} onChange={setNT('requestedBy')} /></Field>
<Field label="Start"><input className="form-control" type="date" value={state.newTicket.startDate} onChange={setNT('startDate')} /></Field>
</div>
<Field label="Details"><textarea className="form-control" rows={3} value={state.newTicket.details} onChange={setNT('details')} /></Field>
<div className="muted" style={{ fontSize: 13 }}>Ticket-Art: {kindObj?.ticketType === 'Migration' ? 'Migration' : 'Webpage'} (ergibt sich aus der Art)</div>
<>
<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 style={{ marginTop: 0, paddingLeft: 18 }}>
<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 && (
@@ -355,41 +372,44 @@ export default function AssignProjectPage() {
)}
</ul>
{isWebsiteWithCustomer && hasPreviewAlready && (
<p className="muted" style={{ marginTop: 0 }}>
<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 && (
<div style={{ marginBottom: 12 }}>
<label className="flex gap-2" style={{ alignItems: 'center' }}>
<>
<label className="check">
<input type="checkbox" checked={previewOn} onChange={(e) => setPreviewOn(e.target.checked)} />
Preview einschalten
</label>
{previewOn && (
<Field label="Subdomain" hint={`Ergebnis: https://${slugify(subdomain) || '<subdomain>'}.project.webklar.com`}>
<input className="form-control" value={subdomain} onChange={(e) => setSubdomain(e.target.value)} />
<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>
</>
)}
<Button variant="primary" disabled={busy || !canFinish(state) || (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && !slugify(subdomain))} onClick={finish}>
{busy ? <FaSpinner className="spinner" /> : <><FaCheck /> Abschließen</>}
<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 && <div className="text-red" style={{ marginTop: 10 }}>{error}</div>}
{error && <p className="msg err" role="alert">{error}</p>}
<div className="flex gap-2" style={{ marginTop: 16 }}>
<div className="row-buttons">
{state.step !== 'kind' && (
<Button variant="ghost" disabled={busy} onClick={() => go(prevStep(state))}><FaArrowLeft /> Zurück</Button>
<Button type="button" variant="ghost" disabled={busy} onClick={() => go(prevStep(state))}>Zurück</Button>
)}
{(state.step === 'kind' || state.step === 'customer' || state.step === 'ticket') && (
<Button variant="primary" disabled={!stepReady()} onClick={() => go(nextStep(state))}>Weiter <FaArrowRight /></Button>
<Button type="button" variant="primary" disabled={!stepReady()} onClick={() => go(nextStep(state))}>Weiter</Button>
)}
</div>
</div>
</Card>
</div>
)
@@ -401,5 +421,5 @@ function TicketLink({ ticketId }) {
databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticketId)
.then((t) => setWoid(t.woid)).catch(() => setWoid(null))
}, [ticketId])
return woid ? <Link to={ticketPath(woid)}>Zum Ticket #{woid}</Link> : null
return woid ? <Link className="link" to={ticketPath(woid)}>Zum Ticket #{woid}</Link> : null
}
+5
View File
@@ -1183,6 +1183,11 @@ button.list-row:hover, button.list-row[aria-pressed="true"] { background: var(--
button.list-row .cell-sub { display: block; }
button.list-row:focus-visible { outline-offset: -2px; } /* in .pick-list (Scroll-Container) wuerde der Ring sonst abgeschnitten */
.summary { list-style: none; display: flex; flex-direction: column; gap: 6px; }
/* Inhalt eines Schritts als Spalte mit Abstand (der Reset setzt jeden Aussenabstand auf 0); Segment und Haken bleiben so breit wie ihr Inhalt */
.assign-step { display: flex; flex-direction: column; gap: 12px; }
.assign-step > .seg, .assign-step > .check { align-self: flex-start; }
.page .assign-step .field { margin-bottom: 0; } /* KOMPAT gibt .page .field 12 px unten; hier steht der Abstand der Spalte (P10 loescht beide) */
button.list-row:hover .faint, button.list-row[aria-pressed="true"] .faint { color: var(--text-2); } /* .faint hat auf --bg-hover nur 2,9:1 (Muster: .table tr.row:hover .faint) */
/* ===== ENDE Projekte (P5) ===== */
/* ===== KOMPAT (bis P10) ===== */