Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
471c7a056d | ||
|
|
c16049d266 | ||
|
|
8653756935 | ||
|
|
7e79dac6b4 | ||
|
|
8185081921 | ||
|
|
9dc447e92c |
No files matched your search
+10
-3
@@ -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 { AuthProvider, useAuth } from './context/AuthContext'
|
||||||
import Sidebar from './components/Navbar'
|
import Sidebar from './components/Navbar'
|
||||||
import Footer from './components/Footer'
|
import Footer from './components/Footer'
|
||||||
@@ -11,10 +11,14 @@ import CustomersPage from './pages/CustomersPage'
|
|||||||
import CustomerDetailPage from './pages/CustomerDetailPage'
|
import CustomerDetailPage from './pages/CustomerDetailPage'
|
||||||
import FinancePage from './pages/FinancePage'
|
import FinancePage from './pages/FinancePage'
|
||||||
import LegalPage from './pages/LegalPage'
|
import LegalPage from './pages/LegalPage'
|
||||||
|
import AssignProjectPage from './pages/AssignProjectPage'
|
||||||
|
import ServicesPage from './pages/ServicesPage'
|
||||||
import ServerpulsPage from './pages/ServerpulsPage'
|
import ServerpulsPage from './pages/ServerpulsPage'
|
||||||
|
import { safeReturnPath } from './lib/returnPath'
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -26,7 +30,7 @@ function ProtectedRoute({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
return children
|
return children
|
||||||
@@ -34,10 +38,11 @@ function ProtectedRoute({ children }) {
|
|||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<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="/" element={<Navigate to="/tickets" replace />} />
|
||||||
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
|
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
|
||||||
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
|
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
|
||||||
@@ -48,6 +53,8 @@ function AppRoutes() {
|
|||||||
<Route path="/serverpuls" element={<ProtectedRoute><ServerpulsPage /></ProtectedRoute>} />
|
<Route path="/serverpuls" element={<ProtectedRoute><ServerpulsPage /></ProtectedRoute>} />
|
||||||
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
||||||
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></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>} />
|
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
|
||||||
{/* Alte Links (z. B. die entfernten Seiten Assets, Reports, Docs, Roadmaps) landen bei den Tickets */}
|
{/* Alte Links (z. B. die entfernten Seiten Assets, Reports, Docs, Roadmaps) landen bei den Tickets */}
|
||||||
<Route path="*" element={<Navigate to="/tickets" replace />} />
|
<Route path="*" element={<Navigate to="/tickets" replace />} />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
IconScale,
|
IconScale,
|
||||||
IconActivityHeartbeat,
|
IconActivityHeartbeat,
|
||||||
IconFolders,
|
IconFolders,
|
||||||
|
IconLayoutGrid,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
@@ -56,6 +57,7 @@ export default function Sidebar() {
|
|||||||
{ label: 'Tickets', href: '/tickets', icon: <IconTicket /> },
|
{ label: 'Tickets', href: '/tickets', icon: <IconTicket /> },
|
||||||
{ label: 'Planboard', href: '/planboard', icon: <IconCalendar /> },
|
{ label: 'Planboard', href: '/planboard', icon: <IconCalendar /> },
|
||||||
{ label: 'Projekte', href: '/projects', icon: <IconFolders /> },
|
{ label: 'Projekte', href: '/projects', icon: <IconFolders /> },
|
||||||
|
{ label: 'Services', href: '/services', icon: <IconLayoutGrid /> },
|
||||||
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
|
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
|
||||||
// Leads liegen auf Eis (Lead-Automatik gestoppt 2026-09-30): grau, nicht klickbar
|
// Leads liegen auf Eis (Lead-Automatik gestoppt 2026-09-30): grau, nicht klickbar
|
||||||
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow />, paused: true },
|
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow />, paused: true },
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
|||||||
import { createProjectFromTemplate, importNewRepos } from '../lib/projectAdminApi'
|
import { createProjectFromTemplate, importNewRepos } from '../lib/projectAdminApi'
|
||||||
import { isOutsideOrg, assignSummary } from '../lib/projectAssign'
|
import { isOutsideOrg, assignSummary } from '../lib/projectAssign'
|
||||||
import AssignedProjectCard from './AssignedProjectCard'
|
import AssignedProjectCard from './AssignedProjectCard'
|
||||||
|
import { fetchServices, completeAssignment } from '../lib/projectKindsApi'
|
||||||
|
import { kindLabelFor } from '../lib/assignFlow'
|
||||||
|
|
||||||
function slugify(v) {
|
function slugify(v) {
|
||||||
return String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100)
|
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 [importing, setImporting] = useState(false)
|
||||||
const [importHint, setImportHint] = useState('')
|
const [importHint, setImportHint] = useState('')
|
||||||
const [assignResult, setAssignResult] = useState(null)
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
if (!ticket?.$id) return
|
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)
|
setBusy(true)
|
||||||
setError('')
|
setError('')
|
||||||
setAssignResult(null)
|
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 unassign = (projectId) => run(async () => {
|
||||||
const r = await unassignProject(projectId)
|
const r = await unassignProject(projectId)
|
||||||
if (!r.success) throw new Error(r.error)
|
if (!r.success) throw new Error(r.error)
|
||||||
@@ -121,14 +172,16 @@ export default function TicketProjects({ ticket }) {
|
|||||||
{assigned.length === 0
|
{assigned.length === 0
|
||||||
? <p className="muted" style={{ marginTop: 0 }}>Noch kein Projekt an diesem Ticket.</p>
|
? <p className="muted" style={{ marginTop: 0 }}>Noch kein Projekt an diesem Ticket.</p>
|
||||||
: assigned.map((p) => (
|
: assigned.map((p) => (
|
||||||
|
<div key={p.$id}>
|
||||||
|
<span className="muted" style={{ fontSize: 12 }} data-testid="project-kind">{kindLabelFor(kinds, p.kind)}</span>
|
||||||
<AssignedProjectCard
|
<AssignedProjectCard
|
||||||
key={p.$id}
|
|
||||||
project={p}
|
project={p}
|
||||||
showActions
|
showActions
|
||||||
onUnassign={unassign}
|
onUnassign={unassign}
|
||||||
customerName={ticket.customerName || ''}
|
customerName={ticket.customerName || ''}
|
||||||
onChanged={load}
|
onChanged={load}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="ticket-projects-assign">
|
<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>
|
<button type="submit" className="ui-btn ui-btn-primary" disabled={busy}>{busy ? 'Wird angelegt …' : 'Anlegen & zuweisen'}</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -168,7 +168,7 @@ function CreateLeadForm({ onCancel, onDone }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fester Kunde mit Zugang zum Kundenportal (nur Admins; legt den Login im Portal an)
|
// 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 [form, setForm] = useState({ name: '', email: '', code: '', phone: '', location: '', password: '' })
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import LetterGlitch from '../components/LetterGlitch'
|
import LetterGlitch from '../components/LetterGlitch'
|
||||||
|
import { safeReturnPath } from '../lib/returnPath'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
@@ -13,6 +14,8 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const { login, mfaPending, sendCode, verifyCode, cancelMfa } = useAuth()
|
const { login, mfaPending, sendCode, verifyCode, cancelMfa } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const target = safeReturnPath(location.state?.from)
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -23,7 +26,7 @@ export default function LoginPage() {
|
|||||||
const result = await login(email, password)
|
const result = await login(email, password)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
navigate('/tickets')
|
navigate(target, { replace: true })
|
||||||
} else if (result.mfaRequired) {
|
} else if (result.mfaRequired) {
|
||||||
if (result.error) setError(result.error)
|
if (result.error) setError(result.error)
|
||||||
} else {
|
} else {
|
||||||
@@ -52,7 +55,7 @@ export default function LoginPage() {
|
|||||||
const result = await verifyCode(code)
|
const result = await verifyCode(code)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
navigate('/tickets')
|
navigate(target, { replace: true })
|
||||||
} else {
|
} else {
|
||||||
setError(result.error)
|
setError(result.error)
|
||||||
setCode('')
|
setCode('')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
FaFolder,
|
FaFolder,
|
||||||
FaPlus,
|
FaPlus,
|
||||||
@@ -88,6 +89,7 @@ function isProjectReady(project) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects()
|
const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects()
|
||||||
const { customers } = useCustomers()
|
const { customers } = useCustomers()
|
||||||
const { workorders } = useWorkorders(WORKORDER_FILTERS)
|
const { workorders } = useWorkorders(WORKORDER_FILTERS)
|
||||||
@@ -223,6 +225,8 @@ export default function ProjectsPage() {
|
|||||||
: 'Projekt (ohne Website) angelegt.'
|
: 'Projekt (ohne Website) angelegt.'
|
||||||
)
|
)
|
||||||
await fetchAllProjects()
|
await fetchAllProjects()
|
||||||
|
// Direkt zur Art-/Kunden-Zuordnung des neuen Repos
|
||||||
|
if (result.projectId) navigate(`/projects/zuordnen/${result.projectId}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCreateError(err.message || 'Projekt konnte nicht angelegt werden')
|
setCreateError(err.message || 'Projekt konnte nicht angelegt werden')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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: '' }])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user