From 8185081921e807577820acc4b4fc6cf458fd469a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 17:42:28 +0000 Subject: [PATCH] Projektarten: Zuordnungs-Ablauf im WOMS Co-Authored-By: Claude Sonnet 5.5 --- src/App.jsx | 2 + src/pages/AssignProjectPage.jsx | 385 ++++++++++++++++++++++++++++++++ src/pages/CustomersPage.jsx | 2 +- 3 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 src/pages/AssignProjectPage.jsx diff --git a/src/App.jsx b/src/App.jsx index 01ccaf7..9487ca4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,6 +11,7 @@ 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 ServerpulsPage from './pages/ServerpulsPage' function ProtectedRoute({ children }) { @@ -48,6 +49,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> {/* Alte Links (z. B. die entfernten Seiten Assets, Reports, Docs, Roadmaps) landen bei den Tickets */} } /> diff --git a/src/pages/AssignProjectPage.jsx b/src/pages/AssignProjectPage.jsx new file mode 100644 index 0000000..33a550c --- /dev/null +++ b/src/pages/AssignProjectPage.jsx @@ -0,0 +1,385 @@ +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 [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) + databases + .listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [ + Query.equal('customerId', state.customerId), + Query.orderDesc('$createdAt'), + Query.limit(200), + ]) + .then((r) => { if (!cancelled) setTickets(r.documents || []) }) + .catch(() => { if (!cancelled) setTickets([]) }) + .finally(() => { if (!cancelled) setTicketsLoading(false) }) + return () => { cancelled = true } + }, [state.step, state.customerId]) + + 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 = '/projects' + 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
+ if (loadError) { + return ( +
+ +
{loadError}
+
+ ) + } + + const header = ( + +
{repoName || project.projectName}
+
+ {repoUrl && Repository in Gitea öffnen} + {project.$createdAt && <> · angelegt am {fmtDate(project.$createdAt)}{project.createdByGitea ? ' (über Gitea)' : ''}} +
+
+ ) + + if (done) { + return ( +
+ + {header} + +
    + {done.warnings.map((w, i) =>
  • {w}
  • )} +
+ +
+
+ ) + } + + // Art bereits gesetzt: nur Hinweis ("Art ändern" bewusst nicht Teil dieser Aufgabe) + if (project.kind) { + return ( +
+ + {header} + + {notice &&
{notice}
} +

+ Bereits erledigt von {project.kindSetBy || 'unbekannt'} am {fmtDate(project.kindSetAt)}. +

+ {project.ticketId && ( +

+ )} +
+
+ ) + } + + const filteredCustomers = (customers || []) + .filter((c) => !isArchived(c)) + .filter((c) => !search || `${c.name} ${c.code || ''} ${c.location || ''}`.toLowerCase().includes(search.toLowerCase())) + + return ( +
+ + {header} + + + {state.step === 'kind' && ( +
+ {kinds.map((k) => ( + + ))} +
+ )} + + {state.step === 'customer?' && ( +
+ + +
+ )} + + {state.step === 'customer' && ( + <> +
+ setSearch(e.target.value)} style={{ maxWidth: 320 }} /> + {isAdmin && ( + + )} +
+ {showNewCustomer && isAdmin && ( +
+ 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 + }} + /> +
+ )} +
+ {filteredCustomers.slice(0, 100).map((c) => ( +
patch({ customerId: c.$id, ticketId: '', newTicket: null })}> +
+
{c.name}{c.code && · {c.code}}
+
{[c.location, c.email].filter(Boolean).join(' · ') || '-'}
+
+ {state.customerId === c.$id && } +
+ ))} + {filteredCustomers.length === 0 &&
Kein Kunde gefunden.
} +
+ + )} + + {state.step === 'ticket' && ( + <> + {ticketsLoading ? : ( +
+ {openTickets.map((t) => ( +
patch({ ticketId: t.$id, newTicket: null })}> +
+
#{t.woid} {t.topic}
+
{[t.type, t.status].filter(Boolean).join(' · ')}
+
+ {state.ticketId === t.$id && } +
+ ))} + {openTickets.length === 0 &&
Dieser Kunde hat keine offenen Tickets.
} +
+ )} + + {state.newTicket && ( +
+
+ + + +
+