Compare commits

..
6 Commits
Author SHA1 Message Date
ClaudeandClaude Sonnet 5.5 471c7a056d Projektarten: Login behaelt Ziel-Link, Hinweise im Ticket-Dialog
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 18:03:40 +00:00
ClaudeandClaude Sonnet 5.5 c16049d266 Projektarten: Art beim Zuweisen und nach Repo-Anlage
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 17:47:02 +00:00
ClaudeandClaude Sonnet 5.5 8653756935 Projektarten: Zuordnen-Seite nachgebessert
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 17:44:55 +00:00
ClaudeandClaude Sonnet 5.5 7e79dac6b4 Projektarten: Bereich Services
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 17:44:55 +00:00
ClaudeandClaude Sonnet 5.5 8185081921 Projektarten: Zuordnungs-Ablauf im WOMS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 17:42:28 +00:00
ClaudeandClaude Sonnet 5.5 9dc447e92c Projektarten: Ablauf-Logik und API-Client
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 17:39:19 +00:00
15 changed files with 861 additions and 16 deletions

No files matched your search

+10 -3
View File
@@ -1,4 +1,4 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import Sidebar from './components/Navbar'
import Footer from './components/Footer'
@@ -11,10 +11,14 @@ import CustomersPage from './pages/CustomersPage'
import CustomerDetailPage from './pages/CustomerDetailPage'
import FinancePage from './pages/FinancePage'
import LegalPage from './pages/LegalPage'
import AssignProjectPage from './pages/AssignProjectPage'
import ServicesPage from './pages/ServicesPage'
import ServerpulsPage from './pages/ServerpulsPage'
import { safeReturnPath } from './lib/returnPath'
function ProtectedRoute({ children }) {
const { user, loading } = useAuth()
const location = useLocation()
if (loading) {
return (
@@ -26,7 +30,7 @@ function ProtectedRoute({ children }) {
}
if (!user) {
return <Navigate to="/login" replace />
return <Navigate to="/login" state={{ from: location }} replace />
}
return children
@@ -34,10 +38,11 @@ function ProtectedRoute({ children }) {
function AppRoutes() {
const { user } = useAuth()
const location = useLocation()
return (
<Routes>
<Route path="/login" element={user ? <Navigate to="/tickets" replace /> : <LoginPage />} />
<Route path="/login" element={user ? <Navigate to={safeReturnPath(location.state?.from)} replace /> : <LoginPage />} />
<Route path="/" element={<Navigate to="/tickets" replace />} />
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
@@ -48,6 +53,8 @@ function AppRoutes() {
<Route path="/serverpuls" element={<ProtectedRoute><ServerpulsPage /></ProtectedRoute>} />
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
<Route path="/services" element={<ProtectedRoute><ServicesPage /></ProtectedRoute>} />
<Route path="/projects/zuordnen/:id" element={<ProtectedRoute><AssignProjectPage /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
{/* Alte Links (z. B. die entfernten Seiten Assets, Reports, Docs, Roadmaps) landen bei den Tickets */}
<Route path="*" element={<Navigate to="/tickets" replace />} />
+2
View File
@@ -9,6 +9,7 @@ import {
IconScale,
IconActivityHeartbeat,
IconFolders,
IconLayoutGrid,
IconCalendar,
IconSettings,
IconLogout,
@@ -56,6 +57,7 @@ export default function Sidebar() {
{ label: 'Tickets', href: '/tickets', icon: <IconTicket /> },
{ label: 'Planboard', href: '/planboard', icon: <IconCalendar /> },
{ label: 'Projekte', href: '/projects', icon: <IconFolders /> },
{ label: 'Services', href: '/services', icon: <IconLayoutGrid /> },
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
// Leads liegen auf Eis (Lead-Automatik gestoppt 2026-09-30): grau, nicht klickbar
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow />, paused: true },
+80 -9
View File
@@ -4,6 +4,8 @@ import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { createProjectFromTemplate, importNewRepos } from '../lib/projectAdminApi'
import { isOutsideOrg, assignSummary } from '../lib/projectAssign'
import AssignedProjectCard from './AssignedProjectCard'
import { fetchServices, completeAssignment } from '../lib/projectKindsApi'
import { kindLabelFor } from '../lib/assignFlow'
function slugify(v) {
return String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100)
@@ -35,6 +37,12 @@ export default function TicketProjects({ ticket }) {
const [importing, setImporting] = useState(false)
const [importHint, setImportHint] = useState('')
const [assignResult, setAssignResult] = useState(null)
const [kinds, setKinds] = useState([])
const [kindDialog, setKindDialog] = useState(false)
useEffect(() => {
fetchServices().then((s) => setKinds(s.kinds || [])).catch(() => {})
}, [])
const load = useCallback(async () => {
if (!ticket?.$id) return
@@ -73,7 +81,17 @@ export default function TicketProjects({ ticket }) {
}
}
const assign = async () => {
const assign = () => {
const project = available.find((p) => p.$id === selected)
// Ohne Art erst "Was ist das?" fragen
if (project && !project.kind) {
setKindDialog(true)
return
}
assignPlain()
}
const assignPlain = async () => {
setBusy(true)
setError('')
setAssignResult(null)
@@ -91,6 +109,39 @@ export default function TicketProjects({ ticket }) {
}
}
// Art gewaehlt: completeAssignment weist serverseitig (wie assignProject) zu,
// daher kein zusaetzliches assignProjects.
// Bei 409 (bereits zugeordnet) bewusst NICHT auf assignProjects zurueckfallen:
// das wuerde das Projekt ohne Art zuweisen. Stattdessen Meldung zeigen und neu laden.
const assignWithKind = async (kind) => {
setKindDialog(false)
setError('')
setAssignResult(null)
if (!ticket.customerId) {
setError('Dieses Ticket hat keinen Kunden. Bitte zuerst einen Kunden am Ticket eintragen.')
return
}
setBusy(true)
try {
const projectName = available.find((p) => p.$id === selected)?.projectName || ''
const r = await completeAssignment(selected, {
kind,
forCustomer: true,
customerId: ticket.customerId,
ticketId: ticket.$id,
})
setAssignResult({ text: `${projectName || 'Projekt'} wurde zugewiesen.`, warnings: r.warnings || [] })
setSelected('')
} catch (err) {
setError(err.status === 409
? `${err.message} Die Liste wurde neu geladen.`
: err.message || 'Aktion fehlgeschlagen')
} finally {
try { await load() } catch { /* Anzeige bleibt wie sie ist */ }
setBusy(false)
}
}
const unassign = (projectId) => run(async () => {
const r = await unassignProject(projectId)
if (!r.success) throw new Error(r.error)
@@ -121,14 +172,16 @@ export default function TicketProjects({ ticket }) {
{assigned.length === 0
? <p className="muted" style={{ marginTop: 0 }}>Noch kein Projekt an diesem Ticket.</p>
: assigned.map((p) => (
<AssignedProjectCard
key={p.$id}
project={p}
showActions
onUnassign={unassign}
customerName={ticket.customerName || ''}
onChanged={load}
/>
<div key={p.$id}>
<span className="muted" style={{ fontSize: 12 }} data-testid="project-kind">{kindLabelFor(kinds, p.kind)}</span>
<AssignedProjectCard
project={p}
showActions
onUnassign={unassign}
customerName={ticket.customerName || ''}
onChanged={load}
/>
</div>
))}
<div className="ticket-projects-assign">
@@ -161,6 +214,24 @@ export default function TicketProjects({ ticket }) {
<button type="submit" className="ui-btn ui-btn-primary" disabled={busy}>{busy ? 'Wird angelegt …' : 'Anlegen & zuweisen'}</button>
</form>
)}
{kindDialog && (
<div className="overlay">
<span className="overlay-close" onClick={() => setKindDialog(false)}>×</span>
<div className="overlay-content">
<h2 className="mb-2">Was ist das?</h2>
<p className="muted">Bitte wähle die Art des Projekts, bevor es zugewiesen wird.</p>
{kinds.length === 0 && (
<div className="text-red" style={{ marginBottom: 8 }}>Arten konnten nicht geladen werden – bitte neu laden.</div>
)}
<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>
)
}
+63
View File
@@ -0,0 +1,63 @@
// Ablauf-Logik des Zuordnen-Assistenten (reine Funktionen, ohne UI).
// state: { step, kind, forCustomer, customerId, ticketId, newTicket }
export function nextStep(state) {
switch (state.step) {
case 'kind':
return 'customer?'
case 'customer?':
return state.forCustomer ? 'customer' : 'summary'
case 'customer':
return 'ticket'
default:
return 'summary'
}
}
export function prevStep(state) {
switch (state.step) {
case 'customer?':
return 'kind'
case 'customer':
return 'customer?'
case 'ticket':
return 'customer'
case 'summary':
return state.forCustomer ? 'ticket' : 'customer?'
default:
return 'kind'
}
}
export function newTicketDefaults({ kindLabel, ticketType, repoName, repoUrl, customer, today }) {
return {
topic: `${kindLabel} – ${repoName}`,
type: ticketType,
requestedBy: customer?.contactName || customer?.name || '',
startDate: today,
details: `Repository: ${repoUrl}`,
}
}
const filled = (v) => typeof v === 'string' && v.trim() !== ''
export function canFinish(state) {
if (!state.kind) return false
if (!state.forCustomer) return true
if (!state.customerId) return false
if (state.ticketId) return true
const t = state.newTicket
return !!t && filled(t.topic) && filled(t.requestedBy) && filled(t.startDate)
}
export function openTicketsFor(tickets, customerId) {
return (tickets || []).filter(
(t) => t.customerId === customerId && t.status !== 'Closed' && t.status !== 'Cancelled'
)
}
// Art-Label fuer die Anzeige; leere Art = "Art fehlt"
export function kindLabelFor(kinds, key) {
if (!key) return 'Art fehlt'
return (kinds || []).find((k) => k.key === key)?.label || key
}
+41
View File
@@ -0,0 +1,41 @@
import { account, databases, DATABASE_ID, COLLECTIONS } from './appwrite'
const PROJECT_ADMIN_URL =
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
// Wie adminFetch in projectAdminApi.js, aber der Fehler trägt .status und .data
// (Antwort-Body), damit die Seite z. B. die Details eines 409 anzeigen kann.
async function kindsFetch(path, options = {}) {
const jwt = await account.createJWT()
const response = await fetch(`${PROJECT_ADMIN_URL}${path}`, {
...options,
headers: {
Authorization: `Bearer ${jwt.jwt}`,
'Content-Type': 'application/json',
...options.headers,
},
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const error = new Error(data.error || `API-Fehler ${response.status}`)
error.status = response.status
error.data = data
throw error
}
return data
}
export function fetchServices() {
return kindsFetch('/api/admin/services')
}
export function completeAssignment(projectId, body) {
return kindsFetch(`/api/admin/website-projects/${projectId}/zuordnen`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function fetchProject(projectId) {
return databases.getDocument(DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, projectId)
}
+8
View File
@@ -0,0 +1,8 @@
// Rueckkehr-Ziel nach dem Login: nur relative Pfade dieser App (kein offener Redirect).
export function safeReturnPath(from, fallback = '/tickets') {
const path = typeof from === 'string' ? from : from?.pathname
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || path.includes('\\')) return fallback
if (path === '/login' || path.startsWith('/login/')) return fallback
const search = typeof from === 'object' && typeof from?.search === 'string' && from.search.startsWith('?') ? from.search : ''
return path + search
}
+34
View File
@@ -0,0 +1,34 @@
// Aufbereitung der /api/admin/services-Antwort für die Seite "Services"
const STATUS_LABELS = { in_development: 'In Entwicklung' }
export function servicesCards(api) {
if (!api) return []
const groups = api.groups || {}
const featureDefs = api.features || {}
const cards = (api.kinds || []).map((k) => {
const projects = groups[k.key] || []
return {
key: k.key,
label: k.label,
description: k.description || '',
count: projects.length,
features: (k.features || []).map((f) => ({
label: featureDefs[f]?.label || f,
statusLabel: STATUS_LABELS[featureDefs[f]?.status] || '',
})),
projects,
}
})
const missing = groups.missing || []
if (missing.length) {
cards.push({
key: 'missing',
label: 'Art fehlt',
description: 'Diese Projekte sind noch keiner Art zugeordnet.',
count: missing.length,
features: [],
projects: missing,
})
}
return cards
}
+397
View File
@@ -0,0 +1,397 @@
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'
import { fetchServices, completeAssignment, fetchProject } from '../lib/projectKindsApi'
import { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } from '../lib/assignFlow'
import { suggestSubdomain } from '../lib/subdomain'
import { isArchived } from '../lib/leads'
import { PageHeader, Card, Button, Field } 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 slugify = (v) =>
String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63)
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
useEffect(() => {
if (state.step === 'summary' && isWebsiteWithCustomer && !subdomain) {
setSubdomain(suggestSubdomain('', repoName))
}
}, [state.step, isWebsiteWithCustomer, 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 && 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 = `/tickets?woid=${encodeURIComponent(t.woid)}`
} 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="empty"><FaSpinner className="spinner" /></div></div>
if (loadError) {
return (
<div className="page">
<PageHeader title="Projekt zuordnen" />
<Card><div className="text-red">{loadError}</div></Card>
</div>
)
}
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>
)
if (done) {
return (
<div className="page">
<PageHeader title="Projekt zuordnen" />
{header}
<Card title="Zugeordnet, mit Hinweisen">
<ul style={{ marginTop: 0, paddingLeft: 18 }}>
{done.warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
<Button variant="primary" onClick={() => navigate(done.path)}>Weiter <FaArrowRight /></Button>
</Card>
</div>
)
}
// Art bereits gesetzt: nur Hinweis ("Art ändern" bewusst nicht Teil dieser Aufgabe)
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 }}>
Bereits erledigt von <strong>{project.kindSetBy || 'unbekannt'}</strong> am {fmtDate(project.kindSetAt)}.
</p>
{project.ticketId && (
<p style={{ marginBottom: 0 }}><TicketLink ticketId={project.ticketId} /></p>
)}
</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">
<PageHeader title="Projekt zuordnen" subtitle={STEP_TITLES[state.step]} />
{header}
<Card title={STEP_TITLES[state.step]}>
{state.step === 'kind' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 10 }}>
{kinds.map((k) => (
<Button
key={k.key}
variant={state.kind === k.key ? 'primary' : 'default'}
style={{ padding: '18px 12px', fontSize: 16, fontWeight: 700 }}
onClick={() => patch({ kind: k.key, newTicket: null, ticketId: '' })}
>
{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 && (
<div style={{ marginBottom: 12 }}>
<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>
)}
<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>
</>
)}
{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>
</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' && (
<>
<ul style={{ marginTop: 0, paddingLeft: 18 }}>
<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 && (
<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>
)}
<Button variant="primary" disabled={busy || !canFinish(state) || (isWebsiteWithCustomer && previewOn && !slugify(subdomain))} onClick={finish}>
{busy ? <FaSpinner className="spinner" /> : <><FaCheck /> Abschließen</>}
</Button>
</>
)}
{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>
</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 to={`/tickets?woid=${encodeURIComponent(woid)}`}>Zum Ticket #{woid}</Link> : null
}
+1 -1
View File
@@ -168,7 +168,7 @@ function CreateLeadForm({ onCancel, onDone }) {
}
// Fester Kunde mit Zugang zum Kundenportal (nur Admins; legt den Login im Portal an)
function CreateCustomerForm({ onCancel, onCreate }) {
export function CreateCustomerForm({ onCancel, onCreate }) {
const [form, setForm] = useState({ name: '', email: '', code: '', phone: '', location: '', password: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
+6 -3
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import LetterGlitch from '../components/LetterGlitch'
import { safeReturnPath } from '../lib/returnPath'
export default function LoginPage() {
const [email, setEmail] = useState('')
@@ -13,6 +14,8 @@ export default function LoginPage() {
const { login, mfaPending, sendCode, verifyCode, cancelMfa } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const target = safeReturnPath(location.state?.from)
const handleSubmit = async (e) => {
e.preventDefault()
@@ -23,7 +26,7 @@ export default function LoginPage() {
const result = await login(email, password)
if (result.success) {
navigate('/tickets')
navigate(target, { replace: true })
} else if (result.mfaRequired) {
if (result.error) setError(result.error)
} else {
@@ -52,7 +55,7 @@ export default function LoginPage() {
const result = await verifyCode(code)
setLoading(false)
if (result.success) {
navigate('/tickets')
navigate(target, { replace: true })
} else {
setError(result.error)
setCode('')
+4
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import {
FaFolder,
FaPlus,
@@ -88,6 +89,7 @@ function isProjectReady(project) {
}
export default function ProjectsPage() {
const navigate = useNavigate()
const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects()
const { customers } = useCustomers()
const { workorders } = useWorkorders(WORKORDER_FILTERS)
@@ -223,6 +225,8 @@ export default function ProjectsPage() {
: 'Projekt (ohne Website) angelegt.'
)
await fetchAllProjects()
// Direkt zur Art-/Kunden-Zuordnung des neuen Repos
if (result.projectId) navigate(`/projects/zuordnen/${result.projectId}`)
} catch (err) {
setCreateError(err.message || 'Projekt konnte nicht angelegt werden')
} finally {
+73
View File
@@ -0,0 +1,73 @@
import { useState, useEffect, useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { FaSpinner } from 'react-icons/fa6'
import { fetchServices } from '../lib/projectKindsApi'
import { servicesCards } from '../lib/servicesView'
import { PageHeader, Card, Button, Badge } from '../components/ui'
export default function ServicesPage() {
const navigate = useNavigate()
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const load = async () => {
setLoading(true)
setError(null)
try {
setData(await fetchServices())
} catch (err) {
setError(err.message || 'Services konnten nicht geladen werden.')
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
const cards = useMemo(() => servicesCards(data), [data])
if (loading) return <div className="page"><div className="empty"><FaSpinner className="spinner" /></div></div>
return (
<div className="page">
<PageHeader title="Services" subtitle="Unsere Projekte nach Art" />
{error && (
<Card>
<div className="text-red" style={{ marginBottom: 8 }}>{error}</div>
<Button onClick={load}>Erneut versuchen</Button>
</Card>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 320px), 1fr))', gap: 12 }}>
{cards.map((c) => (
<Card key={c.key} title={`${c.label} (${c.count})`}>
{c.description && <div className="muted" style={{ marginBottom: 8 }}>{c.description}</div>}
{c.features.length > 0 && (
<div className="flex gap-2 wrap" style={{ marginBottom: 8 }}>
{c.features.map((f) => (
<Badge key={f.label}>{f.label}{f.statusLabel ? ` · ${f.statusLabel}` : ''}</Badge>
))}
</div>
)}
<div className="list">
{c.projects.map((p) => (
<div key={p.id} className="list-row" style={{ flexWrap: 'wrap' }}>
<div className="grow" style={{ minWidth: 0, overflowWrap: 'anywhere' }}>
<div style={{ fontWeight: 700 }}>{p.projectName || p.repoFullName}</div>
<div className="muted">
{[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'}
</div>
</div>
{p.woid && <Link to={`/tickets?woid=${encodeURIComponent(p.woid)}`}>Ticket #{p.woid}</Link>}
{c.key === 'missing' && (
<Button variant="primary" onClick={() => navigate(`/projects/zuordnen/${p.id}`)}>Zuordnen</Button>
)}
</div>
))}
{c.projects.length === 0 && <div className="muted" style={{ padding: 12 }}>Noch keine Projekte.</div>}
</div>
</Card>
))}
</div>
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } = await loadModule(
'src/lib/assignFlow.js'
)
test('nextStep: Kunde ja durchläuft alle Schritte', () => {
const s = { forCustomer: true }
assert.equal(nextStep({ ...s, step: 'kind' }), 'customer?')
assert.equal(nextStep({ ...s, step: 'customer?' }), 'customer')
assert.equal(nextStep({ ...s, step: 'customer' }), 'ticket')
assert.equal(nextStep({ ...s, step: 'ticket' }), 'summary')
assert.equal(nextStep({ ...s, step: 'summary' }), 'summary')
})
test('nextStep: ohne Kunde springt customer? direkt zu summary', () => {
assert.equal(nextStep({ step: 'customer?', forCustomer: false }), 'summary')
})
test('prevStep', () => {
assert.equal(prevStep({ step: 'kind' }), 'kind')
assert.equal(prevStep({ step: 'customer?' }), 'kind')
assert.equal(prevStep({ step: 'customer', forCustomer: true }), 'customer?')
assert.equal(prevStep({ step: 'ticket', forCustomer: true }), 'customer')
assert.equal(prevStep({ step: 'summary', forCustomer: true }), 'ticket')
assert.equal(prevStep({ step: 'summary', forCustomer: false }), 'customer?')
})
test('newTicketDefaults', () => {
const d = newTicketDefaults({
kindLabel: 'Migration/Umzug',
ticketType: 'Migration',
repoName: 'foo',
repoUrl: 'https://git.webklar.com/webklar/foo',
customer: { name: 'Firma', contactName: 'Max' },
today: '2026-09-30',
})
assert.equal(d.topic, 'Migration/Umzug – foo')
assert.equal(d.type, 'Migration')
assert.equal(d.requestedBy, 'Max')
assert.equal(d.startDate, '2026-09-30')
assert.ok(d.details.includes('https://git.webklar.com/webklar/foo'))
const e = newTicketDefaults({ kindLabel: 'Website', ticketType: 'Webpage', repoName: 'x', repoUrl: 'u', customer: { name: 'Firma' }, today: '2026-09-30' })
assert.equal(e.requestedBy, 'Firma')
assert.equal(e.type, 'Webpage')
})
test('canFinish', () => {
assert.equal(canFinish({ kind: '' , forCustomer: false }), false)
assert.equal(canFinish({ kind: 'app', forCustomer: false }), true)
assert.equal(canFinish({ kind: 'app', forCustomer: true }), false)
assert.equal(canFinish({ kind: 'app', forCustomer: true, customerId: 'c1' }), false)
assert.equal(canFinish({ kind: 'app', forCustomer: true, customerId: 'c1', ticketId: 't1' }), true)
const nt = { topic: 'T', requestedBy: 'M', startDate: '2026-09-30', details: 'd' }
assert.equal(canFinish({ kind: 'app', forCustomer: true, customerId: 'c1', newTicket: nt }), true)
assert.equal(canFinish({ kind: 'app', forCustomer: true, customerId: 'c1', newTicket: { ...nt, topic: ' ' } }), false)
assert.equal(canFinish({ kind: 'app', forCustomer: true, newTicket: nt }), false)
})
test('openTicketsFor', () => {
const t = [
{ $id: '1', customerId: 'c1', status: 'Open' },
{ $id: '2', customerId: 'c1', status: 'Closed' },
{ $id: '3', customerId: 'c1', status: 'Cancelled' },
{ $id: '4', customerId: 'c2', status: 'Open' },
{ $id: '5', customerId: 'c1', status: 'In Progress' },
]
assert.deepEqual(openTicketsFor(t, 'c1').map((x) => x.$id), ['1', '5'])
assert.deepEqual(openTicketsFor(null, 'c1'), [])
})
test('kindLabelFor: Label aus Katalog, leer = Art fehlt', async () => {
const { kindLabelFor } = await loadModule('src/lib/assignFlow.js')
const kinds = [{ key: 'website', label: 'Website' }, { key: 'app', label: 'App' }]
assert.equal(kindLabelFor(kinds, 'app'), 'App')
assert.equal(kindLabelFor(kinds, ''), 'Art fehlt')
assert.equal(kindLabelFor(kinds, undefined), 'Art fehlt')
assert.equal(kindLabelFor(kinds, 'unbekannt'), 'unbekannt')
})
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { safeReturnPath } = await loadModule('src/lib/returnPath.js')
test('Pfad und Suchteil bleiben erhalten', () => {
assert.equal(safeReturnPath({ pathname: '/projects/zuordnen/abc', search: '?x=1' }), '/projects/zuordnen/abc?x=1')
assert.equal(safeReturnPath({ pathname: '/projects/zuordnen/abc' }), '/projects/zuordnen/abc')
assert.equal(safeReturnPath('/customers/5'), '/customers/5')
})
test('Fallback bei fehlendem oder unsicherem Ziel', () => {
for (const bad of [undefined, null, {}, '', 'https://evil.example', '//evil.example', '/\\evil', 'javascript:alert(1)', { pathname: '//evil.example' }, { pathname: 'projects' }, { pathname: '/login' }, 42]) {
assert.equal(safeReturnPath(bad), '/tickets', JSON.stringify(bad))
}
})
test('Suchteil ohne ? wird verworfen', () => {
assert.equal(safeReturnPath({ pathname: '/a', search: 'evil' }), '/a')
})
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { servicesCards } = await loadModule('src/lib/servicesView.js')
const kinds = [
{ key: 'website', label: 'Website', description: 'Kundenwebsite', features: ['analyse'] },
{ key: 'app', label: 'App', description: 'Mobile App', features: [] },
]
const features = { analyse: { label: 'Website-Analyse', status: 'in_development' } }
test('servicesCards: Reihenfolge wie Katalog, Zählung, Funktionen', () => {
const cards = servicesCards({
kinds, features,
groups: { app: [{ id: 'a' }, { id: 'b' }], website: [{ id: 'c' }] },
})
assert.deepEqual(cards.map((c) => c.key), ['website', 'app'])
assert.deepEqual(cards.map((c) => c.count), [1, 2])
assert.equal(cards[0].description, 'Kundenwebsite')
assert.deepEqual(cards[0].features, [{ label: 'Website-Analyse', statusLabel: 'In Entwicklung' }])
assert.deepEqual(cards[1].features, [])
assert.equal(cards[1].projects.length, 2)
})
test('servicesCards: Karte "Art fehlt" zuletzt, nur wenn nicht leer', () => {
const none = servicesCards({ kinds, features, groups: { missing: [] } })
assert.equal(none.some((c) => c.key === 'missing'), false)
const cards = servicesCards({ kinds, features, groups: { missing: [{ id: 'x' }] } })
const last = cards[cards.length - 1]
assert.equal(last.key, 'missing')
assert.equal(last.label, 'Art fehlt')
assert.equal(last.count, 1)
})
test('servicesCards: robust bei leerer Antwort und unbekannter Funktion', () => {
assert.deepEqual(servicesCards(null), [])
const cards = servicesCards({ kinds: [{ key: 'x', label: 'X', features: ['foo'] }] })
assert.deepEqual(cards[0].features, [{ label: 'foo', statusLabel: '' }])
})