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:
1 parent
860079f51d
commit
8b688f9df9
2 files changed
+194
-169
No files matched your search
+189
-169
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { FaSpinner, FaPlus, FaArrowLeft, FaArrowRight, FaCheck } from 'react-icons/fa6'
|
|
||||||
import { useCustomers } from '../hooks/useCustomers'
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
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 { suggestSubdomain, slugify } from '../lib/subdomain'
|
||||||
import { isArchived } from '../lib/leads'
|
import { isArchived } from '../lib/leads'
|
||||||
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
|
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'
|
import { CreateCustomerForm } from './CustomersPage'
|
||||||
|
|
||||||
const todayIso = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
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) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="Projekt zuordnen" />
|
<PageHeader crumb={[{ label: 'Projekte', to: '/projects' }]} title="Projekt zuordnen" />
|
||||||
<Card><div className="text-red">{loadError}</div></Card>
|
<p className="msg err" role="alert">{loadError}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createdLine = project.$createdAt && (
|
||||||
|
<> · angelegt am {fmtDate(project.$createdAt)}{project.createdByGitea ? ` von ${project.createdByGitea}` : ''}</>
|
||||||
|
)
|
||||||
const header = (
|
const header = (
|
||||||
<Card>
|
<PageHeader
|
||||||
<div style={{ fontWeight: 700, fontSize: 18 }}>{repoName || project.projectName}</div>
|
crumb={[{ label: 'Projekte', to: '/projects' }, { label: repoName || project.projectName }]}
|
||||||
<div className="muted" style={{ marginTop: 4 }}>
|
title="Projekt zuordnen"
|
||||||
{repoUrl && <a href={repoUrl} target="_blank" rel="noreferrer">Repository in Gitea öffnen</a>}
|
subtitle={(repoUrl || createdLine) ? (
|
||||||
{project.$createdAt && <> · angelegt am {fmtDate(project.$createdAt)}{project.createdByGitea ? ` von ${project.createdByGitea}` : ''}</>}
|
<>
|
||||||
</div>
|
{repoUrl && <a className="link" href={repoUrl} target="_blank" rel="noreferrer">Repository in Gitea öffnen</a>}
|
||||||
</Card>
|
{createdLine}
|
||||||
|
</>
|
||||||
|
) : undefined}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="Projekt zuordnen" />
|
|
||||||
{header}
|
{header}
|
||||||
<Card title="Zugeordnet, mit Hinweisen">
|
<Card title="Zugeordnet, mit Hinweisen">
|
||||||
<ul style={{ marginTop: 0, paddingLeft: 18 }}>
|
<div className="assign-step">
|
||||||
{done.warnings.map((w, i) => <li key={i}>{w}</li>)}
|
<ul className="summary">
|
||||||
</ul>
|
{done.warnings.map((w, i) => <li key={i}>{w}</li>)}
|
||||||
<Button variant="primary" onClick={() => navigate(done.path)}>Weiter <FaArrowRight /></Button>
|
</ul>
|
||||||
|
<div className="row-buttons">
|
||||||
|
<Button type="button" variant="primary" onClick={() => navigate(done.path)}>Weiter</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -215,16 +223,15 @@ export default function AssignProjectPage() {
|
|||||||
if (project.kind) {
|
if (project.kind) {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="Projekt zuordnen" />
|
|
||||||
{header}
|
{header}
|
||||||
<Card>
|
<Card>
|
||||||
{notice && <div className="text-red" style={{ marginBottom: 8 }}>{notice}</div>}
|
<div className="assign-step">
|
||||||
<p style={{ marginTop: 0 }}>
|
{notice && <p className="msg err" role="alert">{notice}</p>}
|
||||||
Bereits erledigt von <strong>{project.kindSetBy || 'unbekannt'}</strong> am {fmtDate(project.kindSetAt)}.
|
<p>
|
||||||
</p>
|
Bereits erledigt von <strong>{project.kindSetBy || 'unbekannt'}</strong> am {fmtDate(project.kindSetAt)}.
|
||||||
{project.ticketId && (
|
</p>
|
||||||
<p style={{ marginBottom: 0 }}><TicketLink ticketId={project.ticketId} /></p>
|
{project.ticketId && <p><TicketLink ticketId={project.ticketId} /></p>}
|
||||||
)}
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -236,159 +243,172 @@ export default function AssignProjectPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="Projekt zuordnen" subtitle={STEP_TITLES[state.step]} />
|
|
||||||
{header}
|
{header}
|
||||||
|
|
||||||
<Card title={STEP_TITLES[state.step]}>
|
<Card title={STEP_TITLES[state.step]}>
|
||||||
{state.step === 'kind' && (
|
<div className="assign-step">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 10 }}>
|
{state.step === 'kind' && (
|
||||||
{kinds.map((k) => (
|
<div className="seg wrap" role="group" aria-label={STEP_TITLES.kind}>
|
||||||
<Button
|
{kinds.map((k) => (
|
||||||
key={k.key}
|
<button
|
||||||
variant={state.kind === k.key ? 'primary' : 'default'}
|
key={k.key}
|
||||||
style={{ padding: '18px 12px', fontSize: 16, fontWeight: 700 }}
|
type="button"
|
||||||
onClick={() => patch({ kind: k.key, newTicket: null, ticketId: '' })}
|
aria-pressed={state.kind === k.key}
|
||||||
>
|
onClick={() => patch({ kind: k.key, newTicket: null, ticketId: '' })}
|
||||||
{k.label}
|
>
|
||||||
</Button>
|
{k.label}
|
||||||
))}
|
</button>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state.step === 'customer?' && (
|
|
||||||
<div className="flex gap-2 wrap">
|
|
||||||
<Button variant="primary" style={{ padding: '14px 28px' }}
|
|
||||||
onClick={() => { patch({ forCustomer: true }); go('customer') }}>Ja, für einen Kunden</Button>
|
|
||||||
<Button style={{ padding: '14px 28px' }}
|
|
||||||
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 }} />
|
|
||||||
{isAdmin && (
|
|
||||||
<Button onClick={() => setShowNewCustomer((s) => !s)}><FaPlus /> 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="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 }}
|
|
||||||
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>}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{state.step === 'ticket' && (
|
{state.step === 'customer?' && (
|
||||||
<>
|
<div className="row-buttons">
|
||||||
{ticketsLoading ? <FaSpinner className="spinner" /> : ticketsError ? (
|
<Button type="button" variant="primary"
|
||||||
<div style={{ marginBottom: 12 }}>
|
onClick={() => { patch({ forCustomer: true }); go('customer') }}>Ja, für einen Kunden</Button>
|
||||||
<div className="text-red" style={{ marginBottom: 8 }}>Die Tickets des Kunden konnten nicht geladen werden.</div>
|
<Button type="button"
|
||||||
<Button onClick={() => setTicketsReload((n) => n + 1)}>Erneut versuchen</Button>
|
onClick={() => { patch({ forCustomer: false, customerId: '', ticketId: '', newTicket: null }); go('summary') }}>Nein, internes Projekt</Button>
|
||||||
</div>
|
</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 }}
|
|
||||||
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>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!ticketsError && !ticketsLoading && (
|
|
||||||
<Button variant={state.newTicket ? 'primary' : 'default'} onClick={startNewTicket}><FaPlus /> Neues Ticket anlegen</Button>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state.step === 'summary' && (
|
{state.step === 'customer' && (
|
||||||
<>
|
<>
|
||||||
<ul style={{ marginTop: 0, paddingLeft: 18 }}>
|
<div className="row-buttons">
|
||||||
<li>Art: <strong>{kindObj?.label}</strong></li>
|
<input className="input search" aria-label="Kunde suchen" placeholder="Kunde suchen …" value={search}
|
||||||
<li>{state.forCustomer ? <>Kunde: <strong>{customer?.name}</strong></> : 'Internes Projekt (ohne Kunde)'}</li>
|
onChange={(e) => setSearch(e.target.value)} />
|
||||||
{state.forCustomer && (
|
{isAdmin && (
|
||||||
<li>
|
<Button type="button" onClick={() => setShowNewCustomer((s) => !s)}>Neuer Kunde</Button>
|
||||||
{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" style={{ marginTop: 0 }}>
|
|
||||||
{['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' }}>
|
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
<Button variant="primary" disabled={busy || !canFinish(state) || (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && !slugify(subdomain))} onClick={finish}>
|
{(state.step === 'kind' || state.step === 'customer' || state.step === 'ticket') && (
|
||||||
{busy ? <FaSpinner className="spinner" /> : <><FaCheck /> Abschließen</>}
|
<Button type="button" variant="primary" disabled={!stepReady()} onClick={() => go(nextStep(state))}>Weiter</Button>
|
||||||
</Button>
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <div className="text-red" style={{ marginTop: 10 }}>{error}</div>}
|
|
||||||
|
|
||||||
<div className="flex gap-2" style={{ marginTop: 16 }}>
|
|
||||||
{state.step !== 'kind' && (
|
|
||||||
<Button variant="ghost" disabled={busy} onClick={() => go(prevStep(state))}><FaArrowLeft /> Zurück</Button>
|
|
||||||
)}
|
|
||||||
{(state.step === 'kind' || state.step === 'customer' || state.step === 'ticket') && (
|
|
||||||
<Button variant="primary" disabled={!stepReady()} onClick={() => go(nextStep(state))}>Weiter <FaArrowRight /></Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -401,5 +421,5 @@ function TicketLink({ ticketId }) {
|
|||||||
databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticketId)
|
databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticketId)
|
||||||
.then((t) => setWoid(t.woid)).catch(() => setWoid(null))
|
.then((t) => setWoid(t.woid)).catch(() => setWoid(null))
|
||||||
}, [ticketId])
|
}, [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
|
||||||
}
|
}
|
||||||
@@ -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 .cell-sub { display: block; }
|
||||||
button.list-row:focus-visible { outline-offset: -2px; } /* in .pick-list (Scroll-Container) wuerde der Ring sonst abgeschnitten */
|
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; }
|
.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) ===== */
|
/* ===== ENDE Projekte (P5) ===== */
|
||||||
|
|
||||||
/* ===== KOMPAT (bis P10) ===== */
|
/* ===== KOMPAT (bis P10) ===== */
|
||||||
|
|||||||
Reference in new issue
Block a user