Merge origin/test (WOMS-Überarbeitung Planboard/Tickets) in feature/oeffnungszeiten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 08:56:02 +00:00
commit 35e2761e68
9002 files changed
+12366 -1176888

No files matched your search

+36 -14
View File
@@ -1,9 +1,11 @@
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useCallback, useEffect, useState } from 'react'
import { BrowserRouter, Routes, Route, Navigate, Link, useLocation } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import Sidebar from './components/Navbar'
import Footer from './components/Footer'
import Logo from './components/Logo'
import LoginPage from './pages/LoginPage'
import TicketsPage from './pages/TicketsPage'
import TicketPage from './pages/TicketPage'
import PlanboardPage from './pages/PlanboardPage'
import ProjectsPage from './pages/ProjectsPage'
import AdminPage from './pages/AdminPage'
@@ -28,12 +30,7 @@ function ProtectedRoute({ children }) {
const location = useLocation()
if (loading) {
return (
<div className="text-center p-4" style={{ marginTop: 80 }}>
<div className="spinner"></div>
<p>Loading...</p>
</div>
)
return <div className="loading">Wird geladen …</div>
}
if (!user) {
@@ -52,6 +49,7 @@ function AppRoutes() {
<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="/tickets/:ref/:tab?" element={<ProtectedRoute><TicketPage /></ProtectedRoute>} />
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
{/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */}
<Route path="/customers/chat" element={<ProtectedRoute><CustomerChatPage /></ProtectedRoute>} />
@@ -79,19 +77,43 @@ function AppRoutes() {
function AppContent() {
const { user } = useAuth()
const location = useLocation()
// Schublade des Menues auf dem Handy; auf dem Desktop steht das Menue fest und menuOpen bleibt ohne Wirkung
const [menuOpen, setMenuOpen] = useState(false)
const open = useCallback(() => setMenuOpen(true), [])
const close = useCallback(() => setMenuOpen(false), [])
// Seitenwechsel (nur der Pfad, nicht ?woid= oder ?tab=): Schublade zu und wieder nach oben
useEffect(() => {
setMenuOpen(false)
window.scrollTo(0, 0)
}, [location.pathname])
// Esc schliesst die Schublade, nur solange sie offen ist und nur, wenn kein anderer Baustein die Taste schon behandelt hat
useEffect(() => {
if (!menuOpen) return undefined
const onKey = (e) => {
if (e.defaultPrevented) return
if (e.key === 'Escape') setMenuOpen(false)
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [menuOpen])
if (!user) {
return <AppRoutes />
}
return (
<div className="app-shell">
<Sidebar />
<div className="app-main">
<div className="app-scroll">
<AppRoutes />
<div className={`shell${menuOpen ? ' menu-open' : ''}`}>
<Sidebar onNavigate={close} />
<div className="scrim" onClick={close} />
<div className="main">
<div className="mobile-bar">
<Link className="brand" to="/tickets"><Logo />WEBklar</Link>
<button type="button" className="btn btn-quiet" onClick={open} aria-expanded={menuOpen} aria-controls="menue">Menü</button>
</div>
<Footer />
<main id="inhalt"><AppRoutes /></main>
</div>
</div>
)
-94
View File
@@ -1,94 +0,0 @@
Lato Regular 1.105 (lato-regular.ttf)
Copyright (c) 2010-2013 by tyPoland Lukasz Dziedzic with Reserved Font Name "Lato".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import { fetchAnalyse, setAnalyse } from '../lib/analyseApi'
import { analyseStatus, textformLabel, canSubmitTextform, channelLabel, legalLinks, chatTemplate, privacyHint } from '../lib/analyseView'
const COLORS = { green: '#10b981', red: '#ef4444', grey: '#a0aec0' }
const COLORS = { green: 'var(--green-fg)', red: 'var(--red-fg)', grey: 'var(--text-2)' }
// Kompakte Zeile "Website-Analyse" je Projekt (lädt beim Einbinden; mit key=Projekt-ID verwenden)
export default function AnalyseRow({ projectId }) {
+12 -12
View File
@@ -10,21 +10,21 @@ import { needsPreviewButton, assignSummary } from '../lib/projectAssign'
import { updateWebsiteProject, backfillProjectPushes } from '../lib/projectAdminApi'
const STATUS_COLORS = {
ready: '#10b981',
live: '#10b981',
online: '#10b981',
provisioning: '#f59e0b',
pending: '#f59e0b',
building: '#3b82f6',
error: '#ef4444',
failed: '#ef4444',
ready: 'var(--green-fg)',
live: 'var(--green-fg)',
online: 'var(--green-fg)',
provisioning: 'var(--orange-fg)',
pending: 'var(--orange-fg)',
building: 'var(--blue-fg)',
error: 'var(--red-fg)',
failed: 'var(--red-fg)',
}
function StatusBadge({ label, value }) {
if (!value) return null
const color = STATUS_COLORS[String(value).toLowerCase()] || '#a0aec0'
const color = STATUS_COLORS[String(value).toLowerCase()] || 'var(--text-2)'
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#cbd5e0', marginRight: 12 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)', marginRight: 12 }}>
{label}:
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, display: 'inline-block' }} />
@@ -79,7 +79,7 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
}
return (
<div style={{ background: 'rgba(30,41,59,0.5)', border: '1px solid rgba(59,130,246,0.25)', borderRadius: 10, padding: '12px 14px', marginBottom: 12 }}>
<div style={{ background: 'var(--bg-subtle)', border: '1px solid var(--line)', borderRadius: 10, padding: '12px 14px', marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div style={{ minWidth: 0 }}>
<strong>{project.projectName}</strong>
@@ -99,7 +99,7 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
title="Vom Ticket lösen"
aria-label="Vom Ticket lösen"
onClick={() => onUnassign(project.$id)}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 6, background: 'rgba(239, 68, 68, 0.12)', border: '1px solid rgba(239, 68, 68, 0.35)', color: '#f87171', cursor: 'pointer' }}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 6, background: 'var(--red-bg)', border: '1px solid var(--line)', color: 'var(--red-fg)', cursor: 'pointer' }}
>
<FaLinkSlash size={16} />
</button>
+133 -143
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useMemo } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useEmployees } from '../hooks/useEmployees'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useScrollIntoView } from '../hooks/useScrollIntoView'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import {
SERVICE_TYPES,
@@ -11,7 +10,7 @@ import {
rankSuggestions,
} from '../lib/ticketForm'
import { priorityFor } from '../lib/ticketStatus'
import { AutocompleteInput, DateTimeInput } from './ui'
import { AutocompleteInput, Button, DateTimeInput, Dialog, Field } from './ui'
// Neue Tickets gibt es aktuell nur fuer diese Arten; alte Tickets behalten ihre Art.
// Migration = ein Kunde, den wir von einem anderen Anbieter uebernehmen.
@@ -36,14 +35,18 @@ const emptyForm = () => ({
details: ''
})
export default function CreateTicketModal({ isOpen, onClose, onCreate, customers = [] }) {
const { employees } = useEmployees()
// Stabile Vorgabe, damit die Vorschlagsliste nicht bei jedem Render neu entsteht
const NO_EMPLOYEES = []
// `employees` kommt von der Seite (useEmployees laeuft dort einmal), der Dialog ruft den Hook nicht selbst auf
export default function CreateTicketModal({ isOpen, onClose, onCreate, customers = [], employees = NO_EMPLOYEES }) {
const [formData, setFormData] = useState(emptyForm)
const [recentTickets, setRecentTickets] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const errorRef = useRef(null)
useScrollIntoView(errorRef, error)
// Reset error when modal opens
useEffect(() => {
@@ -122,145 +125,132 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
if (!isOpen) return null
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content">
<h2 className="mb-2">Neues Ticket</h2>
<Dialog
as="form"
wide
title="Neues Ticket"
busy={loading}
onClose={onClose}
onSubmit={handleSubmit}
footer={
<>
<span className="grow" />
<Button variant="primary" type="submit" disabled={loading}>
{loading ? 'Wird angelegt …' : 'Ticket anlegen'}
</Button>
</>
}
>
{error && (
<div className="msg err" role="alert" ref={errorRef}>
{error}
</div>
)}
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<div className="field-row">
<Field label="Kunde" htmlFor="ticket-customer">
<select
id="ticket-customer"
className="select"
value={formData.customerId}
onChange={(e) => handleChange('customerId', e.target.value)}
required
>
<option value="">Kunde wählen</option>
{customers.map(c => (
<option key={c.$id} value={c.$id}>({c.code || ''}) {c.name || 'Unnamed'}</option>
))}
</select>
</Field>
<form onSubmit={handleSubmit}>
<div className="row">
<div className="col col-6">
<div className="form-group">
<label className="form-label">Kunde</label>
<select
className="form-control"
value={formData.customerId}
onChange={(e) => handleChange('customerId', e.target.value)}
required
>
<option value="">Kunde wählen</option>
{customers.map(c => (
<option key={c.$id} value={c.$id}>({c.code || ''}) {c.name || 'Unnamed'}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Art</label>
<select
className="form-control"
value={formData.type}
onChange={(e) => handleChange('type', e.target.value)}
>
{TICKET_TYPES.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.urgent}
onChange={(e) => handleChange('urgent', e.target.checked)}
/>
Dringend
</label>
</div>
<div className="form-group automatic-assignment-note">
<label className="form-label">Zuweisung</label>
<p>
{employees.length > 0
? `Automatisch an den Mitarbeiter mit der geringsten Anzahl offener Tickets (${employees.length} verfügbar).`
: 'Es ist noch kein Mitarbeiter verfügbar. Das Ticket bleibt zunächst offen.'}
</p>
</div>
</div>
<div className="col col-6">
<div className="form-group">
<label className="form-label">Betreff</label>
<input
type="text"
className="form-control"
placeholder="Worum geht es?"
value={formData.topic}
onChange={(e) => handleChange('topic', e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="ticket-requested-by">Angefragt von</label>
<AutocompleteInput
id="ticket-requested-by"
placeholder="Name"
value={formData.requestedBy}
onChange={(value) => handleChange('requestedBy', value)}
getSuggestions={getSuggestions}
required
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="ticket-start-date">Beginn</label>
<DateTimeInput
id="ticket-start-date"
timeLabel="Uhrzeit Beginn"
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="ticket-end-date">Deadline</label>
<DateTimeInput
id="ticket-end-date"
timeLabel="Uhrzeit Deadline"
min={formData.startDate}
date={formData.deadline}
time={formData.endTime}
onDateChange={(value) => handleChange('deadline', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</div>
</div>
</div>
<div className="form-group">
<label className="form-label">Beschreibung</label>
<textarea
className="form-control"
rows={5}
placeholder="Was soll gemacht werden?"
value={formData.details}
onChange={(e) => handleChange('details', e.target.value)}
/>
</div>
<div className="text-center mt-2">
<button
type="submit"
className="btn btn-dark"
disabled={loading}
>
{loading ? 'Wird angelegt …' : 'Ticket anlegen'}
</button>
</div>
</form>
<Field label="Art" htmlFor="ticket-type">
<select
id="ticket-type"
className="select"
value={formData.type}
onChange={(e) => handleChange('type', e.target.value)}
>
{TICKET_TYPES.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</Field>
</div>
</div>
<Field label="Betreff" htmlFor="ticket-topic">
<input
id="ticket-topic"
type="text"
className="input"
placeholder="Worum geht es?"
value={formData.topic}
onChange={(e) => handleChange('topic', e.target.value)}
required
/>
</Field>
<Field label="Angefragt von" htmlFor="ticket-requested-by">
<AutocompleteInput
id="ticket-requested-by"
placeholder="Name"
value={formData.requestedBy}
onChange={(value) => handleChange('requestedBy', value)}
getSuggestions={getSuggestions}
required
/>
</Field>
<div className="field-row">
<Field label="Beginn" htmlFor="ticket-start-date">
<DateTimeInput
id="ticket-start-date"
timeLabel="Uhrzeit Beginn"
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</Field>
<Field label="Deadline" htmlFor="ticket-end-date">
<DateTimeInput
id="ticket-end-date"
timeLabel="Uhrzeit Deadline"
min={formData.startDate}
date={formData.deadline}
time={formData.endTime}
onDateChange={(value) => handleChange('deadline', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</Field>
</div>
<Field label="Beschreibung" htmlFor="ticket-details">
<textarea
id="ticket-details"
className="textarea"
rows={5}
placeholder="Was soll gemacht werden?"
value={formData.details}
onChange={(e) => handleChange('details', e.target.value)}
/>
</Field>
<label className="check">
<input
type="checkbox"
checked={formData.urgent}
onChange={(e) => handleChange('urgent', e.target.checked)}
/>
Dringend
</label>
<Field
label="Zuweisung"
hint={employees.length > 0
? `Automatisch an den Mitarbeiter mit der geringsten Anzahl offener Tickets (${employees.length} verfügbar).`
: 'Es ist noch kein Mitarbeiter verfügbar. Das Ticket bleibt zunächst offen.'}
/>
</Dialog>
)
}
+147 -155
View File
@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useState, useEffect, useRef } from 'react'
import { useScrollIntoView } from '../hooks/useScrollIntoView'
import { useAuth } from '../context/AuthContext'
import { SERVICE_TYPES, todayIso, isoToGermanDate, timeToWoms, minutesBetween } from '../lib/ticketForm'
import { TICKET_STATUSES, statusLabel } from '../lib/ticketStatus'
import { DateTimeInput, DurationSelect } from './ui'
import { Button, DateTimeInput, Dialog, DurationSelect, Field } from './ui'
import { roundUpToStep, isValidStep } from '../lib/timeSteps'
import { validateSupporters } from '../lib/support'
import SupportersEditor from './SupportersEditor'
@@ -35,7 +35,10 @@ const initialForm = (workorder, newStatus) => ({
supporters: []
})
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate, aboHoursAllowed = true }) {
// Mitarbeiterliste der Seite (useEmployees laeuft dort einmal); der Editor ruft den Hook nicht selbst auf
const NO_EMPLOYEES_STATE = { employees: [], loading: false, error: null }
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate, aboHoursAllowed = true, employeesState = NO_EMPLOYEES_STATE }) {
const { user } = useAuth()
const isAcquisition = workorder?.type === 'Akquise'
@@ -51,6 +54,8 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [autoCalculate, setAutoCalculate] = useState(true)
const errorRef = useRef(null)
useScrollIntoView(errorRef, error)
// Reset form wenn Modal geöffnet wird
useEffect(() => {
@@ -148,159 +153,146 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
if (!isOpen || !workorder) return null
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content">
<h2 className="mb-2">Neues Arbeitsblatt – Ticket {workorder.woid}</h2>
<Dialog
as="form"
wide
title={`Neues Arbeitsblatt – Ticket ${workorder.woid}`}
busy={loading}
onClose={onClose}
onSubmit={handleSubmit}
footer={
<>
<span className="grow" />
<Button variant="primary" type="submit" disabled={loading}>
{loading ? 'Speichert …' : 'Speichern'}
</Button>
</>
}
>
{error && (
<div className="msg err" role="alert" ref={errorRef}>
{error}
</div>
)}
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<div className="field-row">
<Field label="Art" htmlFor="worksheet-service-type">
<select
id="worksheet-service-type"
className="select"
value={formData.serviceType}
onChange={(e) => handleChange('serviceType', e.target.value)}
required
>
{SERVICE_TYPES.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</Field>
<form onSubmit={handleSubmit}>
<div className="row">
<div className="col col-6">
<div className="form-group">
<label className="form-label">Art</label>
<select
className="form-control"
value={formData.serviceType}
onChange={(e) => handleChange('serviceType', e.target.value)}
required
>
{SERVICE_TYPES.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">{isAcquisition ? 'Akquise-Ergebnis' : 'Neuer Status'}</label>
<select
className="form-control"
value={formData.newStatus}
onChange={(e) => handleChange('newStatus', e.target.value)}
required
>
{statusOptions.map(status => (
<option key={status.value} value={status.value}>{status.label}</option>
))}
</select>
{isAcquisition && (
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
"Zugesagt" macht den Lead automatisch zum festen Kunden.
</small>
)}
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={formData.isComment}
onChange={(e) => handleChange('isComment', e.target.checked)}
/>
Nur Kommentar (keine Arbeitszeit)
</label>
</div>
{aboHoursAllowed && (
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }} title="Zeit wird vom Stundenguthaben des Wartungs-Abos abgezogen und im Kundenportal angezeigt">
<input
type="checkbox"
checked={formData.countsToPlan && !formData.isComment}
disabled={formData.isComment}
onChange={(e) => handleChange('countsToPlan', e.target.checked)}
/>
Vom Abo-Stundenkonto abziehen
</label>
</div>
)}
</div>
<div className="col col-6">
<div className="form-group">
<label className="form-label" htmlFor="worksheet-start-date">Beginn</label>
<DateTimeInput
id="worksheet-start-date"
timeLabel="Uhrzeit Beginn"
required
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-end-date">Ende</label>
<DateTimeInput
id="worksheet-end-date"
timeLabel="Uhrzeit Ende"
required
min={formData.startDate}
date={formData.endDate}
time={formData.endTime}
onDateChange={(value) => handleChange('endDate', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-total">Arbeitszeit</label>
<DurationSelect
id="worksheet-total"
value={formData.isComment ? 0 : formData.totalTime}
onChange={(value) => handleChange('totalTime', value)}
disabled={formData.isComment}
/>
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
{autoCalculate && formData.startTime && formData.endTime
? '✓ Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: autoCalculate ? 'Beginn und Ende eintragen oder Zeit wählen' : 'Von Hand gewählt'}
</small>
</div>
</div>
</div>
{!formData.isComment && (
<SupportersEditor
value={formData.supporters}
onChange={(list) => handleChange('supporters', list)}
excludeUserId={user?.$id}
minutes={formData.totalTime}
/>
)}
<div className="form-group">
<label className="form-label">Was wurde gemacht?</label>
<textarea
className="form-control"
rows={5}
placeholder="Beschreibe die durchgeführten Arbeiten..."
value={formData.details}
onChange={(e) => handleChange('details', e.target.value)}
required
/>
</div>
<div className="text-center mt-2">
<button
type="submit"
className="btn btn-dark"
disabled={loading}
>
{loading ? 'Speichert …' : 'Speichern'}
</button>
</div>
</form>
<Field
label={isAcquisition ? 'Akquise-Ergebnis' : 'Neuer Status'}
htmlFor="worksheet-new-status"
hint={isAcquisition ? '"Zugesagt" macht den Lead automatisch zum festen Kunden.' : undefined}
>
<select
id="worksheet-new-status"
className="select"
value={formData.newStatus}
onChange={(e) => handleChange('newStatus', e.target.value)}
required
>
{statusOptions.map(status => (
<option key={status.value} value={status.value}>{status.label}</option>
))}
</select>
</Field>
</div>
</div>
<label className="check">
<input
type="checkbox"
checked={formData.isComment}
onChange={(e) => handleChange('isComment', e.target.checked)}
/>
Nur Kommentar (keine Arbeitszeit)
</label>
{aboHoursAllowed && (
<label className="check" title="Zeit wird vom Stundenguthaben des Wartungs-Abos abgezogen und im Kundenportal angezeigt">
<input
type="checkbox"
checked={formData.countsToPlan && !formData.isComment}
disabled={formData.isComment}
onChange={(e) => handleChange('countsToPlan', e.target.checked)}
/>
Vom Abo-Stundenkonto abziehen
</label>
)}
<div className="field-row">
<Field label="Beginn" htmlFor="worksheet-start-date">
<DateTimeInput
id="worksheet-start-date"
timeLabel="Uhrzeit Beginn"
required
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</Field>
<Field label="Ende" htmlFor="worksheet-end-date">
<DateTimeInput
id="worksheet-end-date"
timeLabel="Uhrzeit Ende"
required
min={formData.startDate}
date={formData.endDate}
time={formData.endTime}
onDateChange={(value) => handleChange('endDate', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</Field>
</div>
<Field
label="Arbeitszeit"
htmlFor="worksheet-total"
hint={autoCalculate && formData.startTime && formData.endTime
? 'Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: autoCalculate ? 'Beginn und Ende eintragen oder Zeit wählen' : 'Von Hand gewählt'}
>
<DurationSelect
id="worksheet-total"
value={formData.isComment ? 0 : formData.totalTime}
onChange={(value) => handleChange('totalTime', value)}
disabled={formData.isComment}
/>
</Field>
{!formData.isComment && (
<SupportersEditor
value={formData.supporters}
onChange={(list) => handleChange('supporters', list)}
excludeUserId={user?.$id}
minutes={formData.totalTime}
employeesState={employeesState}
/>
)}
<Field label="Was wurde gemacht?" htmlFor="worksheet-details">
<textarea
id="worksheet-details"
className="textarea"
rows={5}
placeholder="Beschreibe die durchgeführten Arbeiten..."
value={formData.details}
onChange={(e) => handleChange('details', e.target.value)}
required
/>
</Field>
</Dialog>
)
}
-37
View File
@@ -1,37 +0,0 @@
import { FaUserPlus } from 'react-icons/fa6'
import { useEmployees } from '../hooks/useEmployees'
export default function EditorDropdown({ value, onChange }) {
const { employees } = useEmployees()
// Finde den zugewiesenen Mitarbeiter anhand der userId
const assignedEmployee = employees.find(emp => emp.userId === value)
// Zeige Kürzel wenn zugewiesen, sonst Icon
const displayValue = assignedEmployee?.shortcode || <FaUserPlus size={20} />
return (
<div className="dropdown">
<button className="btn" style={{ background: 'inherit', color: 'inherit' }}>
{displayValue}
</button>
<div className="dropdown-content">
<span
className="dropdown-item"
onClick={() => onChange('')}
>
<em>(Unassigned)</em>
</span>
{employees.map(employee => (
<span
key={employee.$id}
className="dropdown-item"
onClick={() => onChange(employee.userId)}
>
{employee.displayName}{employee.shortcode ? ` (${employee.shortcode})` : ''}
</span>
))}
</div>
</div>
)
}
-9
View File
@@ -1,9 +0,0 @@
export default function Footer() {
return (
<footer className="footer">
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
Webklar Ticket-Plattform · © {new Date().getFullYear()}
</p>
</footer>
)
}
+3 -3
View File
@@ -17,9 +17,9 @@ export default function GiteaLinkButton({ href, title = 'In Gitea oeffnen', size
width: 32,
height: 32,
borderRadius: 6,
background: 'rgba(99, 102, 241, 0.15)',
border: '1px solid rgba(99, 102, 241, 0.35)',
color: '#818cf8',
background: 'var(--violet-bg)',
border: '1px solid var(--line)',
color: 'var(--violet-fg)',
textDecoration: 'none',
flexShrink: 0,
}}
-245
View File
@@ -1,245 +0,0 @@
import { useRef, useEffect } from 'react';
const LetterGlitch = ({
glitchColors = ['#2b4539', '#61dca3', '#61b3dc'],
className = '',
glitchSpeed = 50,
centerVignette = false,
outerVignette = true,
smooth = true,
characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>.,0123456789'
}) => {
const canvasRef = useRef(null);
const animationRef = useRef(null);
const letters = useRef([]);
const grid = useRef({ columns: 0, rows: 0 });
const context = useRef(null);
const lastGlitchTime = useRef(Date.now());
const lettersAndSymbols = Array.from(characters);
const fontSize = 16;
const charWidth = 10;
const charHeight = 20;
const getRandomChar = () => {
return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)];
};
const getRandomColor = () => {
return glitchColors[Math.floor(Math.random() * glitchColors.length)];
};
const hexToRgb = hex => {
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
return r + r + g + g + b + b;
});
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
}
: null;
};
const interpolateColor = (start, end, factor) => {
const result = {
r: Math.round(start.r + (end.r - start.r) * factor),
g: Math.round(start.g + (end.g - start.g) * factor),
b: Math.round(start.b + (end.b - start.b) * factor)
};
return `rgb(${result.r}, ${result.g}, ${result.b})`;
};
const calculateGrid = (width, height) => {
const columns = Math.ceil(width / charWidth);
const rows = Math.ceil(height / charHeight);
return { columns, rows };
};
const initializeLetters = (columns, rows) => {
grid.current = { columns, rows };
const totalLetters = columns * rows;
letters.current = Array.from({ length: totalLetters }, () => ({
char: getRandomChar(),
color: getRandomColor(),
targetColor: getRandomColor(),
colorProgress: 1
}));
};
const resizeCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const parent = canvas.parentElement;
if (!parent) return;
const dpr = window.devicePixelRatio || 1;
const rect = parent.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
if (context.current) {
context.current.setTransform(dpr, 0, 0, dpr, 0, 0);
}
const { columns, rows } = calculateGrid(rect.width, rect.height);
initializeLetters(columns, rows);
drawLetters();
};
const drawLetters = () => {
if (!context.current || letters.current.length === 0) return;
const ctx = context.current;
const { width, height } = canvasRef.current.getBoundingClientRect();
ctx.clearRect(0, 0, width, height);
ctx.font = `${fontSize}px monospace`;
ctx.textBaseline = 'top';
letters.current.forEach((letter, index) => {
const x = (index % grid.current.columns) * charWidth;
const y = Math.floor(index / grid.current.columns) * charHeight;
ctx.fillStyle = letter.color;
ctx.fillText(letter.char, x, y);
});
};
const updateLetters = () => {
if (!letters.current || letters.current.length === 0) return;
const updateCount = Math.max(1, Math.floor(letters.current.length * 0.05));
for (let i = 0; i < updateCount; i++) {
const index = Math.floor(Math.random() * letters.current.length);
if (!letters.current[index]) continue;
letters.current[index].char = getRandomChar();
letters.current[index].targetColor = getRandomColor();
if (!smooth) {
letters.current[index].color = letters.current[index].targetColor;
letters.current[index].colorProgress = 1;
} else {
letters.current[index].colorProgress = 0;
}
}
};
const handleSmoothTransitions = () => {
let needsRedraw = false;
letters.current.forEach(letter => {
if (letter.colorProgress < 1) {
letter.colorProgress += 0.05;
if (letter.colorProgress > 1) letter.colorProgress = 1;
const startRgb = hexToRgb(letter.color);
const endRgb = hexToRgb(letter.targetColor);
if (startRgb && endRgb) {
letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress);
needsRedraw = true;
}
}
});
if (needsRedraw) {
drawLetters();
}
};
const animate = () => {
const now = Date.now();
if (now - lastGlitchTime.current >= glitchSpeed) {
updateLetters();
drawLetters();
lastGlitchTime.current = now;
}
if (smooth) {
handleSmoothTransitions();
}
animationRef.current = requestAnimationFrame(animate);
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
context.current = canvas.getContext('2d');
resizeCanvas();
animate();
let resizeTimeout;
const handleResize = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
cancelAnimationFrame(animationRef.current);
resizeCanvas();
animate();
}, 100);
};
window.addEventListener('resize', handleResize);
return () => {
cancelAnimationFrame(animationRef.current);
window.removeEventListener('resize', handleResize);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [glitchSpeed, smooth]);
const containerStyle = {
position: 'relative',
width: '100%',
height: '100%',
backgroundColor: '#000000',
overflow: 'hidden'
};
const canvasStyle = {
display: 'block',
width: '100%',
height: '100%'
};
const outerVignetteStyle = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
background: 'radial-gradient(circle, rgba(0,0,0,0) 60%, rgba(0,0,0,1) 100%)'
};
const centerVignetteStyle = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
background: 'radial-gradient(circle, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 60%)'
};
return (
<div style={containerStyle} className={className}>
<canvas ref={canvasRef} style={canvasStyle} />
{outerVignette && <div style={outerVignetteStyle}></div>}
{centerVignette && <div style={centerVignetteStyle}></div>}
</div>
);
};
export default LetterGlitch;
+17
View File
@@ -0,0 +1,17 @@
// WEBklar-Zeichen (Ellipse mit Stern); Pfade aus dem Prototyp. Die Groesse setzen im Menue und auf der Anmeldung die Stile
// (.brand svg, .login-box svg), size gilt, wo keine Regel greift.
export default function Logo({ size = 24 }) {
return (
<svg viewBox="0 0 512 512" width={size} height={size} aria-hidden="true">
<g transform="translate(256 256) scale(0.98) translate(-256 -256)">
<ellipse cx="256" cy="256" rx="210" ry="150" fill="#111" transform="rotate(-28 256 256)" />
<circle cx="356" cy="172" r="18" fill="#fff" />
<path
transform="translate(256 256) rotate(-28)"
d="M 0,-170 C 22,-105 60,-72 120,-56 C 60,-40 22,-8 0,64 C -22,-8 -60,-40 -120,-56 C -60,-72 -22,-105 0,-170 Z"
fill="#fff"
/>
</g>
</svg>
)
}
+33 -97
View File
@@ -1,52 +1,16 @@
import { useEffect, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import {
IconTicket,
IconUsers,
IconTargetArrow,
IconReportMoney,
IconScale,
IconActivityHeartbeat,
IconFolders,
IconLayoutGrid,
IconCalendar,
IconSettings,
IconLogout,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
IconMessages,
IconReceiptRefund,
IconDiscount,
IconMailCog,
} from '@tabler/icons-react'
import Logo from './Logo'
import { serverpulsApi } from '../lib/serverpulsApi'
import { startsCollapsed } from '../lib/sidebar'
import { buildNavLinks } from '../lib/navLinks'
import { useSidebarCounts } from '../hooks/useSidebarCounts'
const ICONS = {
tickets: <IconTicket />,
planboard: <IconCalendar />,
projects: <IconFolders />,
services: <IconLayoutGrid />,
customers: <IconUsers />,
chat: <IconMessages />,
gutschriften: <IconReceiptRefund />,
angebote: <IconDiscount />,
speicher: <IconMailCog />,
leads: <IconTargetArrow />,
finance: <IconReportMoney />,
legal: <IconScale />,
serverpuls: <IconActivityHeartbeat />,
admin: <IconSettings />,
}
export default function Sidebar() {
// Seitenmenue als reine Textliste. onNavigate schliesst die Schublade auf dem Handy (App.jsx).
export default function Sidebar({ onNavigate }) {
const { user, logout, isAdmin } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [collapsed, setCollapsed] = useState(() => startsCollapsed())
const [pulseLevel, setPulseLevel] = useState('ok')
const counts = useSidebarCounts()
@@ -69,7 +33,7 @@ export default function Sidebar() {
navigate('/login')
}
// Eintraege und Unterpunkte (Kunden -> Chat) aus lib/navLinks.js, Icons ueber key
// Eintraege und Unterpunkte (Kunden -> Chat) aus lib/navLinks.js
const links = buildNavLinks({ isAdmin, pulseLevel, counts })
const isActive = (href) =>
@@ -78,79 +42,51 @@ export default function Sidebar() {
const isLinkActive = (link) => isActive(link.href) || (link.also ? isActive(link.also) : false)
return (
<aside className={`side ${collapsed ? 'collapsed' : ''}`}>
<div className="side-head">
<Link to="/" className="side-logo" title="Webklar">W</Link>
<div className="side-brand">
Webklar
<small>Ticket-Plattform</small>
</div>
</div>
<aside className="sidebar" id="menue">
<Link className="brand" to="/tickets" onClick={onNavigate}><Logo />WEBklar</Link>
<nav className="side-nav">
<div className="side-group">
{links.map((link) => link.paused ? (
<span
key={link.href}
className="side-link side-link-paused"
title={`${link.label} (pausiert)`}
aria-disabled="true"
>
{ICONS[link.key]}
<span className="side-link-label">{link.label}</span>
<nav className="nav">
{links.flatMap((link) => [
link.paused ? (
<span key={link.href} className="nav-item paused" aria-disabled="true">
{link.label}
<span className="nav-count">pausiert</span>
</span>
) : (
<Link
key={link.href}
to={link.href}
className={`side-link ${isLinkActive(link) ? 'active' : ''}`}
title={link.label}
className={`nav-item${isLinkActive(link) ? ' active' : ''}`}
onClick={onNavigate}
>
{ICONS[link.key]}
<span className="side-link-label">{link.label}</span>
{link.label}
{link.dot && link.dot !== 'ok' && (
<span
className={`side-dot side-dot-${link.dot}`}
className={`nav-dot${link.dot === 'critical' ? ' critical' : ''}`}
data-testid="sp-nav-dot"
title={link.dot === 'critical' ? 'Critical-Warnung aktiv' : 'Warnung aktiv'}
/>
)}
</Link>
)).flatMap((item, i) => [
item,
...(links[i].children || []).map((child) => (
<Link
key={child.href}
to={child.href}
className={`side-link side-sublink ${isActive(child.href) ? 'active' : ''}`}
title={child.count > 0 ? `${child.label} (${child.count} ${child.key === 'chat' ? 'ungelesen' : 'offen'})` : child.label}
>
{ICONS[child.key]}
<span className="side-link-label">{child.label}</span>
{child.count > 0 && <span className="side-count">{child.count}</span>}
</Link>
)),
])}
</div>
),
...(link.children || []).map((child) => (
<Link
key={child.href}
to={child.href}
className={`nav-item sub${isActive(child.href) ? ' active' : ''}`}
title={child.count > 0 ? `${child.label} (${child.count} ${child.key === 'chat' ? 'ungelesen' : 'offen'})` : undefined}
onClick={onNavigate}
>
{child.label}
{child.count > 0 && <span className={`nav-count${child.hot ? ' hot' : ''}`}>{child.count}</span>}
</Link>
)),
])}
</nav>
<div className="side-foot">
{user && (
<div className="side-link" title={user.name || user.email} style={{ cursor: 'default' }}>
<div className="side-logo" style={{ width: 28, height: 28, fontSize: 12, background: 'linear-gradient(135deg,#667eea,#764ba2)' }}>
{(user.name || user.email || '?').charAt(0).toUpperCase()}
</div>
<span className="side-link-label text-ellipsis">{user.name || user.email}</span>
</div>
)}
<button className="side-toggle" onClick={handleLogout} title="Logout">
<IconLogout size={20} />
<span className="side-link-label">Logout</span>
</button>
<button className="side-toggle" onClick={() => setCollapsed((c) => !c)} title="Menue ein-/ausklappen">
{collapsed ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutSidebarLeftCollapse size={20} />}
<span className="side-link-label">Einklappen</span>
</button>
<div className="sidebar-foot">
{user && <span className="who text-ellipsis">{user.name || user.email}</span>}
<button type="button" className="link" onClick={handleLogout}>Abmelden</button>
</div>
</aside>
)
+63 -49
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useEffect, useRef, useState } from 'react'
import { useScrollIntoView } from '../hooks/useScrollIntoView'
import { parseSupporters, supportMinutes } from '../lib/support'
import { stepLabel } from '../lib/timeSteps'
import { Button, Dialog, Field } from './ui'
/* "Ich habe unterstuetzt": die eigene Unterstuetzung bei einem fremden Arbeitsblatt
eintragen, aendern oder entfernen. Andere Eintraege und die Zeiten bleiben unberuehrt. */
@@ -10,17 +11,30 @@ export default function OwnSupportModal({ worksheet, currentUserId, onClose, onS
const [task, setTask] = useState(existing?.task || '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const taskRef = useRef(null)
const errorRef = useRef(null)
useScrollIntoView(errorRef, error)
// Der Dialog setzt den Fokus beim Oeffnen auf sich selbst; dieser Effekt laeuft danach (Kinder vor Eltern) und holt ihn ins Feld
useEffect(() => {
taskRef.current?.focus()
}, [])
const save = async (value) => {
setSaving(true)
setError('')
const result = await onSave(worksheet, value)
if (result?.success) {
onClose()
return
try {
const result = await onSave(worksheet, value)
if (result?.success) {
onClose()
return
}
setError(result?.error || 'Speichern fehlgeschlagen')
} catch (err) {
setError(err.message || 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
setError(result?.error || 'Speichern fehlgeschlagen')
setSaving(false)
}
const handleSubmit = (e) => {
@@ -33,48 +47,48 @@ export default function OwnSupportModal({ worksheet, currentUserId, onClose, onS
}
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content" style={{ maxWidth: 480 }}>
<h2 className="mb-2">Unterstützung eintragen</h2>
<p className="text-grey" style={{ fontSize: '14px', marginBottom: '16px' }}>
WSID {worksheet.wsid} · {worksheet.employeeName}
<br />Dir werden {stepLabel(supportMinutes(worksheet.totalTime))} angerechnet.
</p>
<Dialog
as="form"
title="Unterstützung eintragen"
busy={saving}
onClose={onClose}
onSubmit={handleSubmit}
footer={
<>
{existing && (
<Button variant="danger" type="button" disabled={saving} onClick={() => save(null)}>
Eintrag entfernen
</Button>
)}
<span className="grow" />
<Button variant="primary" type="submit" disabled={saving}>
{saving ? 'Speichert …' : 'Speichern'}
</Button>
</>
}
>
{error && (
<div className="msg err" role="alert" ref={errorRef}>
{error}
</div>
)}
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<p className="muted">
WSID {worksheet.wsid} · {worksheet.employeeName}
<br />Dir werden {stepLabel(supportMinutes(worksheet.totalTime))} angerechnet.
</p>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label" htmlFor="own-support-task">Wobei?</label>
<input
id="own-support-task"
className="form-control"
maxLength={200}
required
value={task}
onChange={(e) => setTask(e.target.value)}
autoFocus
/>
</div>
<div className="text-center mt-2 flex gap-2" style={{ justifyContent: 'center' }}>
<button type="submit" className="btn btn-dark" disabled={saving}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
{existing && (
<button type="button" className="btn" disabled={saving} onClick={() => save(null)}>
Eintrag entfernen
</button>
)}
</div>
</form>
</div>
</div>
<Field label="Wobei?" htmlFor="own-support-task">
<input
id="own-support-task"
ref={taskRef}
className="input"
maxLength={200}
required
value={task}
onChange={(e) => setTask(e.target.value)}
/>
</Field>
</Dialog>
)
}
+5 -5
View File
@@ -208,13 +208,13 @@ export default function PreviewBlock({ projectId }) {
<span style={{ overflowWrap: 'anywhere' }}>{status.text}</span>
</div>
{data.state === 'failed' && data.error && !isDevUnavailableError(data.error) && (
<div style={{ color: '#f87171', ...small, marginTop: 4, overflowWrap: 'anywhere' }}>{data.error}</div>
<div style={{ color: 'var(--red-fg)', ...small, marginTop: 4, overflowWrap: 'anywhere' }}>{data.error}</div>
)}
</>
)}
{inactive && <div className="text-grey" style={{ ...small, marginTop: 8 }}>{NOT_ACTIVE}.</div>}
{error && <div style={{ color: '#f87171', ...small, marginTop: 8, overflowWrap: 'anywhere' }}>{error}</div>}
{error && <div style={{ color: 'var(--red-fg)', ...small, marginTop: 8, overflowWrap: 'anywhere' }}>{error}</div>}
{data && !inactive && (
<>
@@ -251,7 +251,7 @@ export default function PreviewBlock({ projectId }) {
{logs.map((l) => <option key={l.runFile} value={l.runFile}>{logDate(l.at)}</option>)}
</select>
{logFile && (
<pre style={{ marginTop: 6, maxHeight: 260, overflow: 'auto', background: 'rgba(0,0,0,0.35)', padding: 8, borderRadius: 6, fontFamily: 'monospace', fontSize: 11, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
<pre style={{ marginTop: 6, maxHeight: 260, overflow: 'auto', background: 'var(--bg-subtle)', padding: 8, borderRadius: 6, fontFamily: 'monospace', fontSize: 11, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{logText || '…'}
</pre>
)}
@@ -263,7 +263,7 @@ export default function PreviewBlock({ projectId }) {
<div style={{ marginTop: 6 }}>
{names.length === 0 && <div className="text-grey" style={small}>Keine Variablen.</div>}
{names.map((n) => (
<div key={n} style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', padding: '4px 0', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<div key={n} style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', padding: '4px 0', borderBottom: '1px solid var(--line)' }}>
<code style={{ flex: '1 1 140px', overflowWrap: 'anywhere' }}>{n}</code>
<span className="text-grey">•••</span>
{editName === n ? (
@@ -282,7 +282,7 @@ export default function PreviewBlock({ projectId }) {
<input type="password" autoComplete="off" className="form-control" style={{ flex: '1 1 140px', minWidth: 0 }} placeholder="Wert" onKeyDown={enter(handleAdd)} value={newValue} onChange={(e) => setNewValue(e.target.value)} />
<button type="button" className="btn btn-sm" disabled={busy} onClick={handleAdd}>Hinzufügen</button>
</div>
{envError && <div style={{ color: '#f87171', ...small, marginTop: 4 }}>{envError}</div>}
{envError && <div style={{ color: 'var(--red-fg)', ...small, marginTop: 4 }}>{envError}</div>}
{dirty && (
<div style={{ marginTop: 8 }}>
<button type="button" className="btn btn-sm" disabled={busy} onClick={handleSaveEnv}>Variablen speichern</button>
+3 -3
View File
@@ -30,9 +30,9 @@ export default function PreviewLinkButton({ href, subdomain, title = 'Preview oe
width: 32,
height: 32,
borderRadius: 6,
background: 'rgba(16, 185, 129, 0.15)',
border: '1px solid rgba(16, 185, 129, 0.35)',
color: '#34d399',
background: 'var(--green-bg)',
border: '1px solid var(--line)',
color: 'var(--green-fg)',
textDecoration: 'none',
flexShrink: 0,
}}
+17 -33
View File
@@ -1,17 +1,19 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { IconFolders } from '@tabler/icons-react'
import { Link, useLocation } from 'react-router-dom'
import { Card } from './ui'
import { StatusPill } from './ui/StatusPill'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
import { ticketNumber } from '../lib/ticketView'
/**
* Projekt-Kontextbox im Ticket: zeigt automatisch das zugewiesene Projekt
* und alle anderen Tickets desselben Kunden (klickbar).
* Kontextbox im Ticket: "Weitere Tickets von <Kunde>" (klickbar).
* Rendert nichts, wenn dem Ticket kein Projekt zugewiesen ist.
*/
export default function ProjectContextPanel({ ticket }) {
const { fetchByTicketId } = useWebsiteProjects()
const location = useLocation()
const [context, setContext] = useState(null)
useEffect(() => {
@@ -23,7 +25,6 @@ export default function ProjectContextPanel({ ticket }) {
if (!cancelled) setContext(null)
return
}
const projectName = assignedProjects[0].projectName || ''
let otherTickets = []
if (ticket.customerId) {
@@ -34,7 +35,7 @@ export default function ProjectContextPanel({ ticket }) {
])
otherTickets = res.documents.filter((t) => t.$id !== ticket.$id)
}
if (!cancelled) setContext({ projectName, otherTickets })
if (!cancelled) setContext({ otherTickets })
} catch {
if (!cancelled) setContext(null)
}
@@ -44,44 +45,27 @@ export default function ProjectContextPanel({ ticket }) {
}, [ticket.$id, ticket.customerId, fetchByTicketId])
if (!context) return null
const { projectName, otherTickets } = context
const { otherTickets } = context
return (
<div className="ui-card pad" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 10 }}>
<h5 style={{ fontWeight: 700, margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
<IconFolders size={18} /> Projekt: {projectName || '-'}
</h5>
</div>
<Card title={ticket.customerName ? `Weitere Tickets von ${ticket.customerName}` : 'Weitere Tickets'}>
{!ticket.customerId ? (
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
Kein Kunde zugeordnet — andere Tickets koennen nicht automatisch verknuepft werden.
</p>
<p className="muted">Kein Kunde zugeordnet — andere Tickets koennen nicht automatisch verknuepft werden.</p>
) : !otherTickets.length ? (
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
Keine weiteren Tickets zu diesem Kunden/Projekt.
</p>
<p className="muted">Keine weiteren Tickets zu diesem Kunden/Projekt.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div>
{otherTickets.map((t) => (
<Link
key={t.$id}
to={`/tickets?woid=${t.woid}`}
style={{
display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, color: 'var(--text)', textDecoration: 'none',
padding: '6px 8px', borderRadius: 8, border: '1px solid var(--border)',
}}
>
<span className="muted" style={{ minWidth: 48, fontWeight: 700 }}>{t.woid}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.topic || t.title || '-'}
<Link key={t.$id} className="list-row" to={ticketPath(ticketRef(t))} state={location.state}>
<span className="cell-title list-row-text">
<span className="id">{ticketNumber(t)}</span>
<span>{t.topic || t.title || '-'}</span>
</span>
<StatusPill status={t.status} />
</Link>
))}
</div>
)}
</div>
</Card>
)
}
-22
View File
@@ -1,22 +0,0 @@
import { TICKET_STATUSES, statusLabel } from '../lib/ticketStatus'
export default function StatusDropdown({ value, onChange }) {
return (
<div className="dropdown">
<button className="btn" style={{ background: 'inherit', color: 'inherit' }}>
{statusLabel(value)}
</button>
<div className="dropdown-content">
{TICKET_STATUSES.map(status => (
<span
key={status.value}
className="dropdown-item"
onClick={() => onChange(status.value)}
>
{status.label}
</span>
))}
</div>
</div>
)
}
+26 -20
View File
@@ -1,14 +1,16 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useEmployees } from '../hooks/useEmployees'
import { supportMinutes } from '../lib/support'
import { stepLabel } from '../lib/timeSteps'
// Stabile Vorgabe, damit `choices` und die Effekte der Aufrufer nicht bei jedem Render neu entstehen
const NO_EMPLOYEES_STATE = { employees: [], loading: false, error: null }
/* Haken "Unterstuetzung" mit Zeilen Mitarbeiter + "Wobei?". Nur fuer die Person, die das
Arbeitsblatt angelegt hat (sie selbst steht nicht in der Auswahl). Die Mitarbeiterliste
laedt erst hier, damit nicht jede Ticket-Karte sie abruft. */
export default function SupportersEditor({ value, onChange, excludeUserId, minutes }) {
const { employees, loading, error } = useEmployees()
kommt von der Seite (employeesState = Werte von useEmployees, dort einmal geladen),
der Editor ruft den Hook nicht selbst auf. */
export default function SupportersEditor({ value, onChange, excludeUserId, minutes, employeesState = NO_EMPLOYEES_STATE }) {
const { employees, loading, error } = employeesState
const [enabled, setEnabled] = useState(value.length > 0)
const perPerson = stepLabel(supportMinutes(minutes))
const choices = employees.filter((e) => e.userId && e.userId !== excludeUserId)
@@ -29,20 +31,20 @@ export default function SupportersEditor({ value, onChange, excludeUserId, minut
}
return (
<div className="form-group supporters-editor">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div className="field supporters-editor">
<label className="check">
<input type="checkbox" checked={enabled} onChange={(e) => toggle(e.target.checked)} />
Unterstützung
</label>
{enabled && (
<div style={{ marginTop: 8 }}>
<>
{!loading && (error || !choices.length) && (
<p className="text-grey" style={{ fontSize: 13 }}>Mitarbeiterliste konnte nicht geladen werden.</p>
<div className="hint">Mitarbeiterliste konnte nicht geladen werden.</div>
)}
{value.map((row, index) => (
<div key={index} className="flex gap-2" style={{ marginBottom: 8, alignItems: 'center' }}>
<div key={index} className="supporter-row">
<select
className="form-control"
className="select"
aria-label="Mitarbeiter"
value={row.employeeId}
onChange={(e) => pick(index, e.target.value)}
@@ -53,23 +55,27 @@ export default function SupportersEditor({ value, onChange, excludeUserId, minut
.map((e) => <option key={e.userId} value={e.userId}>{e.displayName}</option>)}
</select>
<input
className="form-control"
className="input"
aria-label="Wobei?"
placeholder="Wobei?"
maxLength={200}
value={row.task}
onChange={(e) => update(index, { task: e.target.value })}
/>
<span className="text-grey" style={{ whiteSpace: 'nowrap', fontSize: 13 }}>= {perPerson}</span>
<button type="button" className="btn btn-sm" aria-label="Zeile entfernen" onClick={() => remove(index)}>
<FaTimes />
</button>
<div className="sup-meta">
<span>= {perPerson}</span>
<button type="button" className="link" aria-label="Zeile entfernen" onClick={() => remove(index)}>
Entfernen
</button>
</div>
</div>
))}
<button type="button" className="btn btn-sm" onClick={() => onChange([...value, { employeeId: '', name: '', task: '' }])}>
+ weitere Person
</button>
</div>
<div>
<button type="button" className="link" onClick={() => onChange([...value, { employeeId: '', name: '', task: '' }])}>
+ weitere Person
</button>
</div>
</>
)}
</div>
)
-179
View File
@@ -1,179 +0,0 @@
import { useState } from 'react'
import { FaChevronDown, FaPlay, FaStop, FaPlus } from 'react-icons/fa6'
import { formatDistanceToNow, format } from 'date-fns'
import { de } from 'date-fns/locale'
import StatusDropdown from './StatusDropdown'
import UrgentToggle from './UrgentToggle'
import EditorDropdown from './EditorDropdown'
import CreateWorksheetModal from './CreateWorksheetModal'
import WorksheetList from './WorksheetList'
import WorksheetTimesModal from './WorksheetTimesModal'
import OwnSupportModal from './OwnSupportModal'
import WorksheetSummary from './WorksheetSummary'
import TicketProjects from './TicketProjects'
import ProjectContextPanel from './ProjectContextPanel'
import Tabs from './ui/Tabs'
import Badge from './ui/Badge'
import { StatusPill, UrgentPill } from './ui/StatusPill'
import { useWorksheets } from '../hooks/useWorksheets'
import { useCustomerAbo } from '../hooks/useCustomerAbo'
import { useAuth } from '../context/AuthContext'
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
import { canEditWorksheetTimes } from '../lib/ticketForm'
import { canAddOwnSupport } from '../lib/support'
import { ticketTypeLabel } from '../lib/ticketStatus'
// Projekt / Preview steht mit im Details-Reiter
const TABS = [
{ id: 'details', label: 'Details' },
{ id: 'worksheets', label: 'Arbeitsblätter' },
]
export default function TicketCard({ ticket, onUpdate }) {
const { user, employee } = useAuth()
const [expanded, setExpanded] = useState(false)
const [activeTab, setActiveTab] = useState('details')
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
const [editTimesWorksheet, setEditTimesWorksheet] = useState(null)
const [ownSupportWorksheet, setOwnSupportWorksheet] = useState(null)
// Abo-Stundenkonto gibt es nur bei Wartungs-Abo; erst laden, wenn Arbeitsblaetter gebraucht werden
const { hoursAllowed: aboHoursAllowed } = useCustomerAbo(
ticket.customerId,
(expanded && activeTab === 'worksheets') || showCreateWorksheet
)
const { worksheets, loading: worksheetsLoading, createWorksheet, updateWorksheet, updateWorksheetTimes, saveOwnSupport, getTotalTime } = useWorksheets(
expanded ? ticket.woid : null
)
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
const elapsed = formatDistanceToNow(createdAt, { locale: de, addSuffix: true })
const stop = (e) => e.stopPropagation()
// Abo-Stunden: Arbeitszeit vom Stundenguthaben des Wartungs-Abos abziehen oder nicht
const handleTogglePlan = (ws) => updateWorksheet(ws.$id, { countsToPlan: !ws.countsToPlan })
const handleCreateWorksheet = async (worksheetData, currentUser) => {
const result = await createWorksheet(worksheetData, currentUser)
if (result.success && worksheetData.newStatus !== ticket.status) {
await onUpdate(ticket.$id, { status: worksheetData.newStatus })
}
// Akquise-Automatik: Ergebnis im Worksheet steuert den Kundenstatus
if (result.success && ticket.type === 'Akquise' && ticket.customerId) {
const outcome = worksheetData.newStatus
const next = outcome === 'Zugesagt' ? 'customer' : outcome === 'Abgesagt' ? 'lost' : null
if (next) {
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ticket.customerId, {
customerStatus: next,
updatedAt: new Date().toISOString(),
})
} catch { /* nicht kritisch */ }
}
}
return result
}
return (
<div className={`tcard ${expanded ? 'open' : ''}`}>
<div className="tcard-main" onClick={() => setExpanded((e) => !e)}>
<div className="tcard-woid">
<span className="id">{ticket.woid || ticket.$id?.slice(-5)}</span>
<span className="age">{elapsed}</span>
</div>
<div className="tcard-body">
<div className="tcard-row1">
<span className="tcard-customer">{ticket.customerName || 'Unbekannt'}</span>
{ticket.customerLocation && <span className="tcard-loc">· {ticket.customerLocation}</span>}
</div>
<div className="tcard-topic">{ticket.topic || ticket.title || 'Kein Betreff'}</div>
<div className="tcard-badges">
<StatusPill status={ticket.status} />
<UrgentPill priority={ticket.priority} />
{ticket.type && <Badge tone="muted">{ticketTypeLabel(ticket.type)}</Badge>}
</div>
</div>
<div className="tcard-side" onClick={stop}>
<div style={{ minWidth: 120 }}>
<StatusDropdown value={ticket.status} onChange={(v) => onUpdate(ticket.$id, { status: v })} />
</div>
<UrgentToggle value={ticket.priority} onChange={(v) => onUpdate(ticket.$id, { priority: v })} />
<FaChevronDown className={`chevron ${expanded ? 'up' : ''}`} onClick={() => setExpanded((e) => !e)} style={{ cursor: 'pointer' }} />
</div>
</div>
{expanded && (
<div className="tcard-detail">
<div className="flex gap-3 wrap" style={{ marginBottom: 16, fontSize: 13 }}>
<span className="muted"><FaPlay style={{ color: 'var(--ok)' }} /> {ticket.startDate || format(createdAt, 'dd.MM.yyyy')}</span>
<span className="muted"><FaStop style={{ color: 'var(--danger)' }} /> {ticket.deadline || '-'}</span>
{ticket.requestedBy && <span className="muted">Angefragt von: {ticket.requestedBy}</span>}
<span className="grow" />
<div style={{ minWidth: 150 }}><EditorDropdown value={ticket.assignedTo} onChange={(v) => onUpdate(ticket.$id, { assignedTo: v })} /></div>
</div>
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
<div className="ticket-tab-content">
{activeTab === 'details' && (
<div>
<div className="ui-card pad" style={{ marginBottom: 16 }}>
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Ticket-Beschreibung</h5>
<p style={{ whiteSpace: 'pre-wrap', color: 'var(--text-muted)', lineHeight: 1.7, margin: 0 }}>
{ticket.details || 'Keine Details vorhanden.'}
</p>
</div>
<ProjectContextPanel ticket={ticket} />
<TicketProjects ticket={ticket} />
</div>
)}
{activeTab === 'worksheets' && (
<div>
<div className="flex gap-2" style={{ marginBottom: 16 }}>
<button className="ui-btn ui-btn-primary grow" onClick={() => setShowCreateWorksheet(true)}>
<FaPlus /> Arbeitsblatt hinzufügen
</button>
</div>
<WorksheetSummary worksheets={worksheets} />
<WorksheetList
worksheets={worksheets}
totalTime={getTotalTime()}
loading={worksheetsLoading}
canEditTimes={(ws) => canEditWorksheetTimes(ws, user, employee)}
onEditTimes={setEditTimesWorksheet}
canAddSupport={(ws) => canAddOwnSupport(ws, user, employee)}
onOwnSupport={setOwnSupportWorksheet}
currentUserId={user?.$id}
onTogglePlan={handleTogglePlan}
aboHoursAllowed={aboHoursAllowed}
/>
</div>
)}
</div>
</div>
)}
<CreateWorksheetModal isOpen={showCreateWorksheet} onClose={() => setShowCreateWorksheet(false)} workorder={ticket} onCreate={handleCreateWorksheet} aboHoursAllowed={aboHoursAllowed} />
{ownSupportWorksheet && (
<OwnSupportModal
worksheet={ownSupportWorksheet}
currentUserId={user?.$id}
onClose={() => setOwnSupportWorksheet(null)}
onSave={(ws, task) => saveOwnSupport(ws, task, user, employee)}
/>
)}
{editTimesWorksheet && (
<WorksheetTimesModal
worksheet={editTimesWorksheet}
currentUserId={user?.$id}
onClose={() => setEditTimesWorksheet(null)}
onSave={(ws, times) => updateWorksheetTimes(ws, times, user, employee)}
/>
)}
</div>
)
}
+4 -2
View File
@@ -1,7 +1,7 @@
import { isUrgent, priorityFor } from '../lib/ticketStatus'
// Ein Klick markiert ein Ticket als dringend oder wieder als normal
export default function UrgentToggle({ value, onChange }) {
// Ein Klick markiert ein Ticket als dringend oder wieder als normal; onChange bekommt schon die Prioritaetszahl
export default function UrgentToggle({ value, onChange, disabled }) {
const urgent = isUrgent(value)
return (
<button
@@ -10,7 +10,9 @@ export default function UrgentToggle({ value, onChange }) {
aria-pressed={urgent}
title={urgent ? 'Nicht mehr dringend' : 'Als dringend markieren'}
onClick={() => onChange(priorityFor(!urgent))}
disabled={disabled}
>
<span className="sw" aria-hidden="true" />
Dringend
</button>
)
+102 -328
View File
@@ -1,341 +1,115 @@
import { useState } from 'react'
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp, FaPen, FaCheck, FaHandsHelping } from 'react-icons/fa'
import { serviceTypeLabel, needsTimeEntry, worksheetTone } from '../lib/ticketForm'
import { statusLabel } from '../lib/ticketStatus'
import { parseSupporters, supportMinutes, firstName } from '../lib/support'
import { EmptyState } from './ui'
import { needsTimeEntry, isGitWorksheet } from '../lib/ticketForm'
import { parseSupporters } from '../lib/support'
import {
showAboToggle,
statusAfterLabel,
supportLines,
timeLabel,
worksheetBadge,
worksheetKindLabel,
worksheetWhen,
} from '../lib/worksheetView'
// Kopffarbe je Zustand (worksheetTone): orange = Git-Zeit fehlt, gruen = Git-Zeit eingetragen
const TONES = {
'git-open': { border: '#f97316', base: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)', hover: 'linear-gradient(135deg, #ea580c 0%, #c2410c 100%)' },
'git-done': { border: '#10b981', base: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', hover: 'linear-gradient(135deg, #059669 0%, #047857 100%)' },
comment: { border: '#3b82f6', base: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', hover: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)' },
work: { border: '#4a5568', base: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)', hover: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)' },
}
// Hinweise im farbigen Kopf: mit der grauen Badge-Schrift waeren sie kaum lesbar
const headerBadgeStyle = { background: 'rgba(255,255,255,0.3)', color: 'white', marginLeft: '8px' }
const headerButtonStyle = {
background: 'rgba(255,255,255,0.25)',
color: 'white',
border: '1px solid rgba(255,255,255,0.4)',
borderRadius: '6px',
padding: '2px 10px',
fontSize: '0.8rem',
marginRight: '8px',
}
export default function WorksheetList({ worksheets, totalTime, loading, onTogglePlan, aboHoursAllowed = true, canEditTimes, onEditTimes, canAddSupport, onOwnSupport, currentUserId }) {
const [expandedWorksheets, setExpandedWorksheets] = useState({})
if (loading) {
return (
<div className="text-center p-4">
<div className="spinner-border" role="status">
<span className="visually-hidden">Lädt …</span>
</div>
</div>
)
}
// Arbeitsblaetter, neueste zuerst wie geliefert: links Datum und Beginn, in der Mitte Person, Art, Text und Aktionen, rechts die Zeit
export default function WorksheetList({
worksheets,
loading,
onTogglePlan,
aboHoursAllowed = true,
canEditTimes,
onEditTimes,
canAddSupport,
onOwnSupport,
currentUserId,
}) {
if (loading) return <div className="loading">Wird geladen …</div>
if (!worksheets || worksheets.length === 0) {
return (
<div className="alert alert-info" role="alert">
<FaComment className="me-2" />
Noch keine Arbeitsblätter. Leg das erste mit „Arbeitsblatt hinzufügen“ an.
</div>
)
}
const formatTime = (minutes) => {
if (!minutes || minutes === 0) return '-'
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`
}
const formatDateTime = (date, time) => {
if (!time) return date
const hours = time.substring(0, 2)
const mins = time.substring(2, 4)
return `${date} ${hours}:${mins}`
}
const toggleWorksheet = (wsid) => {
setExpandedWorksheets(prev => ({
...prev,
[wsid]: !prev[wsid]
}))
return <EmptyState title="Noch keine Arbeitsblätter." hint="Leg das erste mit „Arbeitsblatt hinzufügen“ an." />
}
return (
<div className="worksheet-list">
{/* Worksheet-Einträge */}
<div className="timeline">
{worksheets.map((ws, index) => {
const isExpanded = expandedWorksheets[ws.wsid] || false
const isGit = ws.serviceType === 'GIT'
const tone = worksheetTone(ws)
const colors = TONES[tone]
const supporters = ws.isComment ? [] : parseSupporters(ws.supporters)
return (
<div key={ws.$id} className="timeline-item mb-4" style={{
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
}}>
<div className="card border-0 shadow-sm overflow-hidden" style={{
borderLeft: `4px solid ${colors.border}`,
borderRadius: '8px',
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
}} onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 8px 16px rgba(0,0,0,0.1)'
}} onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'
}}>
{/* Header - Immer sichtbar, klickbar */}
<div
className="card-header d-flex justify-content-between align-items-center py-3"
onClick={() => toggleWorksheet(ws.wsid)}
style={{
background: colors.base,
color: 'white',
border: 'none',
cursor: 'pointer',
userSelect: 'none',
transition: 'background 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = colors.hover
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = colors.base
}}
>
<div className="d-flex align-items-center gap-3">
{isExpanded ? (
<FaChevronUp style={{ fontSize: '0.9rem', opacity: 0.8 }} />
) : (
<FaChevronDown style={{ fontSize: '0.9rem', opacity: 0.8 }} />
)}
<div>
<strong className="fs-6">WSID {ws.wsid}</strong>
{ws.isComment && !isGit && (
<span className="badge ms-2" style={headerBadgeStyle}>
<FaComment className="me-1" /> Kommentar
</span>
)}
{tone === 'git-open' && (
<span className="badge ms-2" style={headerBadgeStyle}>
<FaClock className="me-1" /> Zeit offen
</span>
)}
{tone === 'git-done' && (
<span className="badge ms-2" style={headerBadgeStyle}>
<FaCheck className="me-1" /> Zeit eingetragen
</span>
)}
{supporters.length > 0 && (
<span className="badge ms-2" title="Weitere Personen haben unterstützt" style={headerBadgeStyle}>
+{supporters.length} Unterstützung
</span>
)}
{ws.countsToPlan && !ws.isComment && (
<span className="badge ms-2" title="Wird vom Stundenguthaben des Wartungs-Abos abgezogen" style={{ ...headerBadgeStyle, background: '#9d3dfe' }}>
Abo-Stunden
</span>
)}
</div>
{/* Collapsed: Mitarbeiter & Zeit im Header */}
{!isExpanded && (
<div className="d-flex align-items-center gap-3 ms-3">
<div className="d-flex align-items-center">
<FaUser style={{ fontSize: '0.9rem', marginRight: '0.5rem' }} />
<span style={{ fontSize: '0.9rem' }}>{ws.employeeName}</span>
</div>
{!ws.isComment && (
<div className="d-flex align-items-center">
<FaClock style={{ fontSize: '0.9rem', marginRight: '0.5rem' }} />
<span style={{ fontSize: '0.9rem' }}>{formatTime(ws.totalTime)}</span>
</div>
)}
<span className="badge" style={{
background: 'rgba(255,255,255,0.2)',
color: 'white',
fontSize: '0.8rem'
}}>{serviceTypeLabel(ws.serviceType)}</span>
</div>
)}
</div>
<div className="d-flex align-items-center gap-2">
{onEditTimes && canEditTimes?.(ws) && (
<button
type="button"
className="btn btn-sm"
title="Beginn, Ende und Arbeitszeit ändern"
style={headerButtonStyle}
onClick={(e) => {
e.stopPropagation()
onEditTimes(ws)
}}
>
{needsTimeEntry(ws)
? <><FaClock className="me-1" /> Zeit nachtragen</>
: <><FaPen className="me-1" /> Zeiten bearbeiten</>}
</button>
)}
{onOwnSupport && canAddSupport?.(ws) && (
<button
type="button"
className="btn btn-sm"
title="Eintragen, dass du bei dieser Arbeit unterstützt hast"
style={headerButtonStyle}
onClick={(e) => {
e.stopPropagation()
onOwnSupport(ws)
}}
>
<FaHandsHelping className="me-1" />{' '}
{parseSupporters(ws.supporters).some((s) => s.employeeId === currentUserId)
? 'Meine Unterstützung bearbeiten'
: 'Ich habe unterstützt'}
</button>
)}
{onTogglePlan && !ws.isComment && Number(ws.totalTime) > 0 && (aboHoursAllowed || ws.countsToPlan) && (
<button
type="button"
className="btn btn-sm"
title={ws.countsToPlan ? 'Nicht mehr vom Abo-Stundenkonto abziehen' : 'Vom Abo-Stundenkonto des Kunden abziehen'}
style={{
background: ws.countsToPlan ? '#9d3dfe' : 'rgba(255,255,255,0.15)',
color: 'white',
border: '1px solid rgba(255,255,255,0.4)',
borderRadius: '6px',
padding: '2px 10px',
fontSize: '0.8rem',
marginRight: '8px',
}}
onClick={(e) => {
e.stopPropagation()
onTogglePlan(ws)
}}
>
{ws.countsToPlan ? '✓ Abo' : '+ Abo'}
</button>
)}
<small style={{ opacity: 0.9 }}>
{formatDateTime(ws.startDate, ws.startTime) || formatDateTime(ws.endDate, ws.endTime)}
</small>
</div>
</div>
{/* Body - Nur wenn expanded */}
{isExpanded && (
<div
className="card-body p-4"
style={{
animation: 'slideDown 0.3s ease-out'
}}
<div className="ws-list">
{worksheets.map((ws) => {
const when = worksheetWhen(ws)
const kind = worksheetKindLabel(ws)
const badge = worksheetBadge(ws)
const lines = supportLines(ws)
const status = statusAfterLabel(ws)
const time = timeLabel(ws)
const canEdit = Boolean(onEditTimes && canEditTimes?.(ws))
const canSupport = Boolean(onOwnSupport && canAddSupport?.(ws))
const ownSupport = parseSupporters(ws.supporters).some((s) => s.employeeId === currentUserId)
const showAbo = Boolean(onTogglePlan && showAboToggle(ws, aboHoursAllowed))
const isNote = ws.isComment && !isGitWorksheet(ws)
return (
<div key={ws.$id} className={isNote ? 'ws comment' : 'ws'}>
<div className="ws-when">
<div className="d">{when.date || '–'}</div>
{when.time && <div className="num">{when.time}</div>}
</div>
<div>
<div className="ws-head">
<span className="who">{ws.employeeName}</span>
{ws.employeeShort && <span className="faint xs">{ws.employeeShort}</span>}
{kind && <span className="muted">{kind}</span>}
<span className="faint xs num">WSID {ws.wsid}</span>
{badge && <span className={`badge ${badge.tone}`}>{badge.text}</span>}
{showAbo ? (
<button
type="button"
className={ws.countsToPlan ? 'abo-toggle on' : 'abo-toggle'}
title={ws.countsToPlan ? 'Nicht mehr vom Abo-Stundenkonto abziehen' : 'Vom Abo-Stundenkonto des Kunden abziehen'}
onClick={() => onTogglePlan(ws)}
>
{/* Mitarbeiter & Zeit */}
<div className="row mb-3">
<div className="col-md-6">
<div className="d-flex align-items-center">
<FaUser className="me-2" style={{ color: '#10b981' }} />
<strong>{ws.employeeName}</strong>
{ws.employeeShort && (
<span className="badge ms-2" style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white'
}}>{ws.employeeShort}</span>
)}
</div>
</div>
<div className="col-md-6 text-md-end">
{!ws.isComment && (
<div className="d-flex align-items-center justify-content-md-end">
<FaClock className="me-2" style={{ color: '#10b981' }} />
<strong className="fs-5" style={{ color: '#10b981' }}>{formatTime(ws.totalTime)}</strong>
</div>
)}
</div>
</div>
{/* Unterstuetzung: je Person die Haelfte der Arbeitszeit, aufgerundet auf 15 Minuten */}
{supporters.length > 0 && (
<div className="mb-3 worksheet-supporters">
{supporters.map((sup) => (
<div key={sup.employeeId} style={{ fontSize: '0.9rem', color: 'var(--text-muted)' }}>
↳ Unterstützung {firstName(sup.name)} · {sup.task} · {formatTime(supportMinutes(ws.totalTime))}
</div>
))}
</div>
)}
{/* Service Type */}
<div className="mb-3">
<span className="badge px-3 py-2" style={{
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white',
fontSize: '0.85rem'
}}>{serviceTypeLabel(ws.serviceType)}</span>
</div>
{/* Status-Änderung */}
{ws.oldStatus !== ws.newStatus && (
<div className="mb-3 p-3 rounded-3" style={{
background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)'
}}>
<FaExchangeAlt className="me-2" style={{ color: '#10b981' }} />
<span className="text-muted">Status:</span>{' '}
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{statusLabel(ws.oldStatus)}</span>
<span className="mx-2" style={{ color: '#10b981' }}>→</span>
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{statusLabel(ws.newStatus)}</span>
</div>
)}
{/* Details */}
<div className="mt-3 p-3 rounded-3" style={{
background: 'rgba(16, 185, 129, 0.05)',
border: '1px solid rgba(16, 185, 129, 0.1)'
}}>
<small className="text-dark" style={{ whiteSpace: 'pre-wrap', lineHeight: '1.6' }}>
{ws.details}
</small>
</div>
</div>
{ws.countsToPlan ? 'Abo' : '+ Abo'}
</button>
) : (
ws.countsToPlan && !ws.isComment && <span className="muted xs">Abo</span>
)}
</div>
{ws.details && <div className="ws-text">{ws.details}</div>}
{lines.map((line, i) => (
<div key={i} className="ws-support">{line}</div>
))}
{status && <div className="ws-support">{status}</div>}
{(canEdit || canSupport) && (
<div className="ws-acts">
{canEdit && (
<button
type="button"
className="link"
title="Beginn, Ende und Arbeitszeit ändern"
onClick={() => onEditTimes(ws)}
>
{needsTimeEntry(ws) ? 'Zeit nachtragen' : 'Zeiten bearbeiten'}
</button>
)}
{canSupport && (
<button
type="button"
className="link"
title="Eintragen, dass du bei dieser Arbeit unterstützt hast"
onClick={() => onOwnSupport(ws)}
>
{ownSupport ? 'Meine Unterstützung bearbeiten' : 'Ich habe unterstützt'}
</button>
)}
</div>
)}
</div>
)
})}
</div>
<style>{`
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
max-height: 1000px;
transform: translateY(0);
}
}
`}</style>
<div className="ws-time">
{time && <div className={time === '–' ? 't faint' : 't'}>{time}</div>}
</div>
</div>
)
})}
</div>
)
}
+9 -17
View File
@@ -1,25 +1,17 @@
import { needsTimeEntry } from '../lib/ticketForm'
import { worksheetMinutes } from '../lib/support'
import { Stat } from './ui'
import { worksheetSums } from '../lib/worksheetView'
import { stepLabel } from '../lib/timeSteps'
const formatTime = (minutes) => {
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`
}
// Eine Zeile ueber der Arbeitsblatt-Liste: Arbeitszeit (inkl. Unterstuetzung), Anzahl, offene Git-Zeiten
// Kennzahlen ueber der Arbeitsblatt-Liste: Arbeitszeit (inkl. Unterstuetzung), davon Abo-Stunden, offene Git-Zeiten
export default function WorksheetSummary({ worksheets }) {
if (!worksheets || worksheets.length === 0) return null
const minutes = worksheets
.filter((ws) => !ws.isComment)
.reduce((sum, ws) => sum + worksheetMinutes(ws), 0)
const open = worksheets.filter(needsTimeEntry).length
const { totalMinutes, aboMinutes, openCount } = worksheetSums(worksheets)
return (
<div className="worksheet-summary">
<span>Arbeitszeit gesamt <strong>{formatTime(minutes)}</strong></span>
<span>{worksheets.length} {worksheets.length === 1 ? 'Arbeitsblatt' : 'Arbeitsblätter'}</span>
{open > 0 && <span className="open">{open} {open === 1 ? 'Zeit offen' : 'Zeiten offen'}</span>}
<div className="stats">
<Stat label="Arbeitszeit gesamt" value={stepLabel(totalMinutes)} />
<Stat label="Davon Abo-Stunden" value={stepLabel(aboMinutes)} />
<Stat label="Zeiten offen" value={openCount} tone={openCount > 0 ? 'warn' : ''} />
</div>
)
}
+78 -75
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useRef, useState } from 'react'
import { useScrollIntoView } from '../hooks/useScrollIntoView'
import { isoToGermanDate, timeToWoms, germanDateToIso, womsTimeToInput, minutesBetween, isGitWorksheet, needsTimeEntry } from '../lib/ticketForm'
import { DateTimeInput, DurationSelect } from './ui'
import { Button, DateTimeInput, Dialog, DurationSelect, Field } from './ui'
import { roundUpToStep, isValidStep } from '../lib/timeSteps'
import { parseSupporters, validateSupporters } from '../lib/support'
import SupportersEditor from './SupportersEditor'
@@ -14,11 +14,14 @@ const formFromWorksheet = (worksheet) => ({
endTime: womsTimeToInput(worksheet.endTime)
})
// Mitarbeiterliste der Seite (useEmployees laeuft dort einmal); der Editor ruft den Hook nicht selbst auf
const NO_EMPLOYEES_STATE = { employees: [], loading: false, error: null }
/**
* Beginn, Ende und Arbeitszeit eines Arbeitsblatts nachtraeglich aendern.
* Wer das darf, entscheidet canEditWorksheetTimes (lib/ticketForm).
*/
export default function WorksheetTimesModal({ worksheet, currentUserId, onClose, onSave }) {
export default function WorksheetTimesModal({ worksheet, currentUserId, onClose, onSave, employeesState = NO_EMPLOYEES_STATE }) {
const isGit = isGitWorksheet(worksheet)
const [formData, setFormData] = useState(() => formFromWorksheet(worksheet))
// null = die Arbeitszeit folgt Beginn und Ende (aufgerundet auf 15 Minuten). Passte sie
@@ -33,6 +36,8 @@ export default function WorksheetTimesModal({ worksheet, currentUserId, onClose,
const [supporters, setSupporters] = useState(() => parseSupporters(worksheet.supporters))
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const errorRef = useRef(null)
useScrollIntoView(errorRef, error)
const calculatedMinutes = minutesBetween(formData.startTime, formData.endTime)
const calculatedTotal = calculatedMinutes === null ? null : roundUpToStep(calculatedMinutes)
@@ -86,81 +91,79 @@ export default function WorksheetTimesModal({ worksheet, currentUserId, onClose,
}
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content" style={{ maxWidth: 560 }}>
{/* Kurzer Titel: auf dem Handy laeuft ein langer unter das Schliessen-X */}
<h2 className="mb-2">{needsTimeEntry(worksheet) ? 'Zeit nachtragen' : 'Zeiten bearbeiten'}</h2>
<p className="text-grey" style={{ fontSize: '14px', marginBottom: '16px' }}>
WSID {worksheet.wsid}
{isGit && <><br />Das Ende ist der Zeitpunkt des Git-Pushs. Trag ein, wann die Arbeit begonnen hat.</>}
</p>
<Dialog
as="form"
title={needsTimeEntry(worksheet) ? 'Zeit nachtragen' : 'Zeiten bearbeiten'}
busy={saving}
onClose={onClose}
onSubmit={handleSubmit}
footer={
<>
<span className="grow" />
<Button variant="primary" type="submit" disabled={saving}>
{saving ? 'Speichert …' : 'Speichern'}
</Button>
</>
}
>
{error && (
<div className="msg err" role="alert" ref={errorRef}>
{error}
</div>
)}
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<p className="muted">
WSID {worksheet.wsid}
{isGit && <><br />Das Ende ist der Zeitpunkt des Git-Pushs. Trag ein, wann die Arbeit begonnen hat.</>}
</p>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-times-start">Beginn</label>
<DateTimeInput
id="worksheet-times-start"
timeLabel="Uhrzeit Beginn"
required
timeRequired={isGit}
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</div>
<Field label="Beginn" htmlFor="worksheet-times-start">
<DateTimeInput
id="worksheet-times-start"
timeLabel="Uhrzeit Beginn"
required
timeRequired={isGit}
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</Field>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-times-end">Ende</label>
<DateTimeInput
id="worksheet-times-end"
timeLabel="Uhrzeit Ende"
required
min={formData.startDate}
date={formData.endDate}
time={formData.endTime}
onDateChange={(value) => handleChange('endDate', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</div>
<Field label="Ende" htmlFor="worksheet-times-end">
<DateTimeInput
id="worksheet-times-end"
timeLabel="Uhrzeit Ende"
required
min={formData.startDate}
date={formData.endDate}
time={formData.endTime}
onDateChange={(value) => handleChange('endDate', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</Field>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-times-total">Arbeitszeit</label>
<DurationSelect
id="worksheet-times-total"
value={totalTime}
onChange={(value) => setManualTotal(value)}
/>
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
{manualTotal === null && calculatedTotal !== null
? '✓ Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: 'Von Hand gewählt'}
</small>
</div>
<Field
label="Arbeitszeit"
htmlFor="worksheet-times-total"
hint={manualTotal === null && calculatedTotal !== null
? 'Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: 'Von Hand gewählt'}
>
<DurationSelect
id="worksheet-times-total"
value={totalTime}
onChange={(value) => setManualTotal(value)}
/>
</Field>
<SupportersEditor
value={supporters}
onChange={setSupporters}
excludeUserId={currentUserId}
minutes={totalTime}
/>
<div className="text-center mt-2">
<button type="submit" className="btn btn-dark" disabled={saving}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
</div>
</form>
</div>
</div>
<SupportersEditor
value={supporters}
onChange={setSupporters}
excludeUserId={currentUserId}
minutes={totalTime}
employeesState={employeesState}
/>
</Dialog>
)
}
+3 -3
View File
@@ -42,9 +42,9 @@ export default function CustomerViewLog({ customerId }) {
<div className="list">
{entries.map((e) => (
<div key={e.id} className="list-row">
<span className="mono muted" style={{ minWidth: 130 }}>{formatViewLogTime(e.at)}</span>
<span style={{ minWidth: 120, fontWeight: 600 }}>{e.staffName || '–'}</span>
<div className="grow">
<span className="mono muted list-row-text" style={{ minWidth: 130 }}>{formatViewLogTime(e.at)}</span>
<span className="list-row-text" style={{ minWidth: 120, fontWeight: 600 }}>{e.staffName || '–'}</span>
<div className="grow list-row-text">
<div>{viewLogLabel(e.action)}</div>
{e.detail && <div className="muted">{e.detail}</div>}
</div>
+8 -7
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import uPlot from 'uplot'
import { formatAxisTick, formatLegendTime } from '../../lib/serverpulsFormat'
import { CHART } from './chartColors'
import 'uplot/dist/uPlot.min.css'
/**
@@ -25,15 +26,15 @@ export default function Chart({ data, series, height = 280, yFmt, yRange }) {
scales: { y: { range: yr || ((u, min, max) => [Math.min(0, min), max === min ? min + 1 : max + (max - min) * 0.08]) } },
axes: [
{
stroke: '#9aa7b4',
grid: { stroke: 'rgba(255,255,255,0.06)' },
ticks: { stroke: 'rgba(255,255,255,0.1)' },
stroke: CHART.axis,
grid: { show: false },
ticks: { stroke: CHART.tick },
values: (u, splits, axisIdx, foundSpace, foundIncr) => splits.map((t) => formatAxisTick(t, foundIncr)),
},
{
stroke: '#9aa7b4',
grid: { stroke: 'rgba(255,255,255,0.06)' },
ticks: { stroke: 'rgba(255,255,255,0.1)' },
stroke: CHART.axis,
grid: { stroke: CHART.grid },
ticks: { stroke: CHART.tick },
size: 70,
values: (u, vals) => vals.map(fmtVal),
},
@@ -42,7 +43,7 @@ export default function Chart({ data, series, height = 280, yFmt, yRange }) {
{ label: 'Zeit', value: (u, v) => formatLegendTime(v) },
...s.map((x) => ({
label: x.label,
stroke: x.color || '#10b981',
stroke: x.color || CHART.line,
width: 2,
spanGaps: false,
points: { show: false },
+11
View File
@@ -0,0 +1,11 @@
// Canvas-Farben fuer uPlot (Serverpuls). Canvas versteht kein var(--...), deshalb stehen die Werte hier als Literale;
// sie entsprechen den Tokens: line = --accent, peak = --text-3, axis = --text-2, grid = --line, tick = --line-strong.
// Einzige Stelle im Quelltext, an der Farbliterale ausserhalb von CSS erlaubt sind.
export const CHART = {
line: '#111111',
fill: 'rgba(17,17,17,.06)',
peak: '#8a93a3',
axis: '#5e6678',
grid: '#ebeef1',
tick: '#d8dde3',
}
+1 -1
View File
@@ -38,7 +38,7 @@ export default function AutocompleteInput({ value, onChange, getSuggestions, id,
<input
type="text"
id={id}
className="form-control"
className="input"
role="combobox"
aria-autocomplete="list"
aria-expanded={showList}
+6 -7
View File
@@ -1,8 +1,7 @@
export default function Badge({ tone = 'muted', dot = false, children, className = '', style }) {
return (
<span className={`badge badge-${tone} ${className}`} style={style}>
{dot && <span className="dot" />}
{children}
</span>
)
import { badgeToneClass } from './tones'
// "dot" bleibt als Prop erlaubt und wird ignoriert
export default function Badge({ tone = 'muted', dot, className = '', style, children }) {
const cls = className ? `${badgeToneClass(tone)} ${className}` : badgeToneClass(tone)
return <span className={cls} style={style}>{children}</span>
}
+4 -7
View File
@@ -1,10 +1,6 @@
const VARIANTS = {
default: 'ui-btn',
primary: 'ui-btn ui-btn-primary',
ghost: 'ui-btn ui-btn-ghost',
danger: 'ui-btn ui-btn-danger',
}
import { BUTTON_CLASSES } from './tones'
// "size" bleibt als Prop erlaubt und wird ignoriert; ein Standard-"type" gibt es nicht
export default function Button({
variant = 'default',
size,
@@ -13,7 +9,8 @@ export default function Button({
children,
...props
}) {
const cls = `${VARIANTS[variant] || VARIANTS.default}${size === 'sm' ? ' ui-btn-sm' : ''} ${className}`
const base = Object.prototype.hasOwnProperty.call(BUTTON_CLASSES, variant) ? BUTTON_CLASSES[variant] : BUTTON_CLASSES.default
const cls = className ? `${base} ${className}` : base
const Comp = as
return (
<Comp className={cls} {...props}>
+11 -15
View File
@@ -1,18 +1,14 @@
export default function Card({ title, actions, children, pad = true, className = '', bodyStyle, style }) {
if (!title && !actions) {
return (
<div className={`ui-card ${pad ? 'pad' : ''} ${className}`} style={style}>
{children}
</div>
)
}
// "pad", "bodyStyle" und "style" bleiben als Props erlaubt und werden ignoriert
export default function Card({ title, actions, className, children }) {
return (
<div className={`ui-card ${className}`} style={style}>
<div className="ui-card-head">
<h3>{title}</h3>
{actions && <div className="flex gap-2 items-center">{actions}</div>}
</div>
<div className="ui-card-body" style={bodyStyle}>{children}</div>
</div>
<section className={className || undefined}>
{(title || actions) && (
<h2 className="section">
<span>{title}</span>
{actions && <span className="actions">{actions}</span>}
</h2>
)}
{children}
</section>
)
}
+2 -2
View File
@@ -15,7 +15,7 @@ export default function DateTimeInput({ id, date, time, onDateChange, onTimeChan
<input
id={id}
type="date"
className="form-control"
className="input"
min={min || undefined}
required={required}
value={date}
@@ -24,7 +24,7 @@ export default function DateTimeInput({ id, date, time, onDateChange, onTimeChan
/>
<input
type="time"
className="form-control"
className="input"
aria-label={timeLabel}
required={timeRequired}
value={time}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useId, useRef } from 'react'
import { useDialogFocus } from '../../hooks/useDialogFocus'
import { shouldCloseOnKey } from '../../lib/dialogKeys'
// Alle gerade offenen Dialoge, der zuletzt geöffnete steht hinten: nur er reagiert auf Esc (sonst schlösse ein Esc auch die darunter liegenden)
const openDialogs = []
/**
* Dialog: weiße Box auf abgedunkeltem Hintergrund, auf dem Handy als Bottom-Sheet (Regeln in global.css, .overlay/.dialog).
* Schließen geht nur über × und Esc, ein Klick auf den Hintergrund tut nichts (lange Eingaben sollen nicht verloren gehen).
* Solange `busy` gilt, wirken × und Esc nicht. Liegen mehrere Dialoge übereinander, schließt Esc nur den obersten.
* Esc greift nur, wenn kein innerer Baustein die Taste schon behandelt hat
* (z. B. schließt eine offene Vorschlagsliste zuerst sich selbst): React 18 verteilt seine Ereignisse am Wurzelelement, also vor
* dem Listener auf `document`, und ein dortiges preventDefault() ist hier als `defaultPrevented` sichtbar.
* Mit `as="form"` ist die Box ein <form onSubmit>; die native Pflichtfeld-Prüfung bleibt aktiv (kein noValidate).
* Der Fokus springt beim Öffnen in die Box und beim Schließen zurück (useDialogFocus); Tab bleibt im Dialog.
*/
export default function Dialog({ title, onClose, busy = false, wide = false, as = 'div', onSubmit, footer, children }) {
const Box = as
const titleId = useId()
const dialogRef = useRef(null)
const trapKeyDown = useDialogFocus(dialogRef)
// Der Listener bleibt über alle Renders registriert und liest immer den neuesten Stand
const latest = useRef({ busy, onClose })
useEffect(() => {
latest.current = { busy, onClose }
})
useEffect(() => {
openDialogs.push(latest)
const onKeyDown = (e) => {
if (openDialogs[openDialogs.length - 1] !== latest) return
const { busy: isBusy, onClose: close } = latest.current
if (shouldCloseOnKey(e, isBusy)) close()
}
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
const index = openDialogs.indexOf(latest)
if (index !== -1) openDialogs.splice(index, 1)
}
}, [])
return (
<div className="overlay" onKeyDown={trapKeyDown}>
<Box
className={wide ? 'dialog dialog-wide' : 'dialog'}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
ref={dialogRef}
tabIndex={-1}
onSubmit={as === 'form' ? onSubmit : undefined}
>
<div className="dialog-head">
<h3 id={titleId}>{title}</h3>
<button type="button" className="dialog-close" aria-label="Schließen" disabled={busy} onClick={() => onClose()}>
×
</button>
</div>
<div className="dialog-body">{children}</div>
{footer && <div className="dialog-foot">{footer}</div>}
</Box>
</div>
)
}
+1 -1
View File
@@ -5,7 +5,7 @@ export default function DurationSelect({ id, value, onChange, disabled }) {
return (
<select
id={id}
className="form-control"
className="select"
value={value}
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))}
+4 -4
View File
@@ -1,10 +1,10 @@
// "icon" bleibt als Prop erlaubt und wird ignoriert
export default function EmptyState({ icon, title, hint, action }) {
return (
<div className="empty">
{icon && <div className="empty-icon">{icon}</div>}
<div style={{ fontWeight: 700, marginBottom: 4 }}>{title}</div>
{hint && <div className="faint" style={{ fontSize: 13 }}>{hint}</div>}
{action && <div style={{ marginTop: 16 }}>{action}</div>}
<strong>{title}</strong>
{hint}
{action && <div className="mt-2">{action}</div>}
</div>
)
}
+3 -3
View File
@@ -1,9 +1,9 @@
export default function Field({ label, children, hint }) {
export default function Field({ label, htmlFor, children, hint }) {
return (
<div className="field">
{label && <label>{label}</label>}
{label && <label htmlFor={htmlFor}>{label}</label>}
{children}
{hint && <div className="faint" style={{ fontSize: 12 }}>{hint}</div>}
{hint && <div className="hint">{hint}</div>}
</div>
)
}
+5 -9
View File
@@ -1,10 +1,6 @@
export default function Kpi({ label, value, sub, icon, accent = 'var(--accent)' }) {
return (
<div className="kpi">
<span className="kpi-accent" style={{ background: accent }} />
<div className="kpi-label">{icon}{label}</div>
<div className="kpi-value">{value}</div>
{sub && <div className="kpi-sub">{sub}</div>}
</div>
)
import Stat from './Stat'
// Altname fuer Stat (wird in P7 geloescht); "icon" wird ignoriert, die Warnfarbe wird zu 'neg'
export default function Kpi({ label, value, sub, icon, accent }) {
return <Stat label={label} value={value} sub={sub} tone={accent === 'var(--danger)' ? 'neg' : ''} />
}
+25 -8
View File
@@ -1,12 +1,29 @@
export default function PageHeader({ title, subtitle, actions, children }) {
import { Fragment } from 'react'
import { Link } from 'react-router-dom'
// crumb: [{ label, to? }] steht vor dem Kopf; Eintraege mit "to" sind Links, das letzte Element ist immer Text
export default function PageHeader({ title, subtitle, actions, eyebrow, crumb, children }) {
return (
<div className="page-header">
<div>
<h1 className="page-title">{title}</h1>
{subtitle && <div className="page-subtitle">{subtitle}</div>}
{children}
<>
{crumb && crumb.length > 0 && (
<nav className="crumb" aria-label="Brotkrumen">
{crumb.map((c, i) => (
<Fragment key={i}>
{i > 0 && <span className="faint">/</span>}
{c.to && i < crumb.length - 1 ? <Link to={c.to}>{c.label}</Link> : <span>{c.label}</span>}
</Fragment>
))}
</nav>
)}
<div className="page-head">
<div>
{eyebrow && <div className="eyebrow">{eyebrow}</div>}
<h1>{title}</h1>
{subtitle && <div className="sub">{subtitle}</div>}
{children}
</div>
{actions && <div className="actions">{actions}</div>}
</div>
{actions && <div className="page-actions">{actions}</div>}
</div>
</>
)
}
+10
View File
@@ -0,0 +1,10 @@
// Kennzahl: Beschriftung, Wert, Zusatzzeile; tone '' | 'warn' | 'neg'. Der Container .stats kommt vom Aufrufer.
export default function Stat({ label, value, sub, tone = '' }) {
return (
<div className="stat">
<div className="k">{label}</div>
<div className={tone ? `v ${tone}` : 'v'}>{value}</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
}
+4 -13
View File
@@ -1,21 +1,12 @@
import { statusLabel, statusClass, isUrgent } from '../../lib/ticketStatus'
const pillBase = {
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '3px 10px',
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
}
import { statusLabel, statusTone, isUrgent } from '../../lib/ticketStatus'
export function StatusPill({ status }) {
return <span className={statusClass(status)} style={pillBase}>{statusLabel(status)}</span>
const tone = statusTone(status)
return <span className={tone ? `badge ${tone}` : 'badge'}>{statusLabel(status)}</span>
}
// Nur dringende Tickets bekommen ein Abzeichen
export function UrgentPill({ priority }) {
if (!isUrgent(priority)) return null
return <span className="priority-critical" style={pillBase}>Dringend</span>
return <span className="badge red">Dringend</span>
}
+5 -2
View File
@@ -1,14 +1,17 @@
export default function Tabs({ tabs, active, onChange }) {
return (
<div className="ticket-tabs">
<div className="tabs" role="tablist">
{tabs.map((t) => (
<button
key={t.id}
type="button"
className={`ticket-tab ${active === t.id ? 'ticket-tab-active' : ''}`}
role="tab"
aria-selected={active === t.id}
className={active === t.id ? 'tab active' : 'tab'}
onClick={() => onChange(t.id)}
>
{t.label}
{t.count != null && <span className="count">{t.count}</span>}
</button>
))}
</div>
+2
View File
@@ -6,7 +6,9 @@ export { StatusPill, UrgentPill } from './StatusPill'
export { default as Tabs } from './Tabs'
export { default as EmptyState } from './EmptyState'
export { default as Field } from './Field'
export { default as Stat } from './Stat'
export { default as Kpi } from './Kpi'
export { default as AutocompleteInput } from './AutocompleteInput'
export { default as DateTimeInput } from './DateTimeInput'
export { default as DurationSelect } from './DurationSelect'
export { default as Dialog } from './Dialog'
+28
View File
@@ -0,0 +1,28 @@
/* Klassen der Bausteine: reine Zuordnung, ohne JSX (Test: tests/unit/uiTones.test.mjs) */
// Badge-Ton (alter und neuer Name) auf die Klasse; "muted"/"grey" und alles Unbekannte = neutral
const BADGE_TONES = {
info: 'blue',
blue: 'blue',
ok: 'green',
green: 'green',
warn: 'orange',
orange: 'orange',
danger: 'red',
red: 'red',
violet: 'violet',
plain: 'plain',
}
export function badgeToneClass(tone) {
const t = Object.prototype.hasOwnProperty.call(BADGE_TONES, tone) ? BADGE_TONES[tone] : ''
return t ? `badge ${t}` : 'badge'
}
export const BUTTON_CLASSES = {
default: 'btn',
ghost: 'btn',
primary: 'btn btn-primary',
quiet: 'btn btn-quiet',
danger: 'link-danger',
}
+7 -3
View File
@@ -1,17 +1,21 @@
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useRef } from 'react'
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
/**
* Fokus für ein Dialog-Overlay: beim Öffnen springt der Fokus in den Dialog (`dialogRef`), beim Schließen zurück zum
* Element, das ihn vorher hatte (falls es noch da ist). Der zurückgegebene onKeyDown gehört an das Overlay: Tab und
* Element, das ihn vorher hatte (falls es noch da ist). Das Element wird beim ersten Render festgehalten, noch bevor
* ein autoFocus im Dialog den Fokus nimmt. Der zurückgegebene onKeyDown gehört an das Overlay: Tab und
* Umschalt+Tab bleiben im Dialog (sonst landet man hinter dem Overlay in der Seite).
*/
export function useDialogFocus(dialogRef) {
const openerRef = useRef(undefined)
if (openerRef.current === undefined) openerRef.current = document.activeElement
useEffect(() => {
const opener = document.activeElement
dialogRef.current?.focus()
return () => {
const opener = openerRef.current
if (opener && opener.isConnected && typeof opener.focus === 'function') opener.focus()
}
}, [dialogRef])
-282
View File
@@ -1,282 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { COLLECTIONS, DATABASE_ID, Query, databases } from '../lib/appwrite'
const PAGE = 100
async function listAll(collection, queries = []) {
const out = []
let offset = 0
for (;;) {
const res = await databases.listDocuments(DATABASE_ID, collection, [
...queries,
Query.limit(PAGE),
Query.offset(offset),
])
out.push(...(res.documents || []))
if ((res.documents || []).length < PAGE) break
offset += PAGE
}
return out
}
/** Haengt an jeden Lead Verweise, die nicht am Lead selbst gespeichert sind,
* sondern ueber den Kunden verknuepft werden — via Lead -> Kunde (per E-Mail):
* - woid: WOID des Akquise-Tickets (workorders type=Akquise)
* - previewUrl: URL der erstellten, deployten Website (websiteProjects)
* Best effort; Fehler brechen die Lead-Liste nicht. */
async function attachLeadLinks(leads) {
const norm = (l) => (l.email || l.portalLogin || '').trim().toLowerCase()
const emails = [...new Set(leads.map(norm).filter(Boolean))]
if (!emails.length) return leads
// 1) Kunden per E-Mail -> customerId
const emailToCustomer = {}
const customerIds = []
for (let i = 0; i < emails.length; i += 90) {
const docs = await listAll(COLLECTIONS.CUSTOMERS, [Query.equal('email', emails.slice(i, i + 90))])
for (const c of docs) {
const e = (c.email || '').trim().toLowerCase()
if (e && !emailToCustomer[e]) {
emailToCustomer[e] = c.$id
customerIds.push(c.$id)
}
}
}
if (!customerIds.length) return leads
// 2) Akquise-Tickets per customerId -> kleinste (urspruengliche) WOID je Kunde
const customerToWoid = {}
for (let i = 0; i < customerIds.length; i += 90) {
const docs = await listAll(COLLECTIONS.WORKORDERS, [
Query.equal('customerId', customerIds.slice(i, i + 90)),
Query.equal('type', ['Akquise']),
])
for (const w of docs) {
const n = parseInt(w.woid, 10)
if (!w.customerId || isNaN(n)) continue
const cur = customerToWoid[w.customerId]
if (!cur || n < cur.n) customerToWoid[w.customerId] = { n, woid: w.woid, ticketId: w.$id }
}
}
// 3) deployte Preview je Kunde -> URL der erstellten Website.
// Die websiteProjects-Collection ist klein -> komplett laden + lokal zuordnen
// (die Preview haengt am selben Kunden, aber an einem anderen Repo als lead.repoUrl).
const customerToPreview = {}
const idSet = new Set(customerIds)
for (const p of await listAll(COLLECTIONS.WEBSITE_PROJECTS)) {
if (!idSet.has(p.customerId) || customerToPreview[p.customerId]) continue
const ready = p.status === 'deployed' || p.status === 'ready' || p.provisioningStatus === 'ready'
const url = p.previewUrl || (p.subdomain ? `https://${p.subdomain}.project.webklar.com` : '')
if (ready && url) customerToPreview[p.customerId] = url
}
// 4) an die Leads haengen
return leads.map((l) => {
const cid = emailToCustomer[norm(l)]
const t = customerToWoid[cid]
const previewUrl = customerToPreview[cid]
if (!t && !previewUrl) return l
return {
...l,
...(t ? { woid: t.woid, ticketId: t.ticketId } : {}),
...(previewUrl ? { previewUrl } : {}),
}
})
}
/** Recherche-Leads aus der taeglichen 06:00-Routine (Appwrite `leads`).
* Pollt automatisch nach — schneller, solange die 5-Minuten-Pipeline
* (processor.py auf dem Server) gerade Leads verarbeitet. */
export function useLeads() {
const [leads, setLeads] = useState([])
const [pipelines, setPipelines] = useState({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const fetchLeads = useCallback(async () => {
try {
const out = await listAll(COLLECTIONS.LEADS, [Query.orderDesc('leadScore')])
let enriched = out
try { enriched = await attachLeadLinks(out) } catch { /* Live-Join best effort */ }
setLeads(enriched)
setError(null)
} catch (err) {
if (err.code === 404 || err.message?.includes('not found')) {
setLeads([])
setError(null)
} else {
setError(err.message)
setLeads([])
}
} finally {
setLoading(false)
}
// Schritt-Log + E-Mail-Entwurf pro Lead (Zusatz-Collection, best effort)
try {
const docs = await listAll(COLLECTIONS.LEAD_PIPELINE)
const map = {}
for (const d of docs) if (d.leadId) map[d.leadId] = d
setPipelines(map)
} catch {
setPipelines({})
}
}, [])
useEffect(() => {
fetchLeads()
}, [fetchLeads])
// Live-Polling: 5s waehrend die Pipeline arbeitet, sonst alle 20s
const pipelineActive = useMemo(
() => leads.some((l) => l.pipelineStatus === 'running' || l.pipelineStatus === 'pending'),
[leads]
)
useEffect(() => {
const t = setInterval(fetchLeads, pipelineActive ? 5000 : 20000)
return () => clearInterval(t)
}, [fetchLeads, pipelineActive])
const updateLead = async (id, data) => {
try {
const doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEADS, id, {
...data,
updatedAt: new Date().toISOString(),
})
setLeads((prev) => prev.map((l) => (l.$id === id ? doc : l)))
return { success: true, data: doc }
} catch (err) {
return { success: false, error: err.message }
}
}
const deleteLead = async (id) => {
try {
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.LEADS, id)
setLeads((prev) => prev.filter((l) => l.$id !== id))
return { success: true }
} catch (err) {
return { success: false, error: err.message }
}
}
return { leads, pipelines, loading, error, refresh: fetchLeads, updateLead, deleteLead }
}
/** 5-Minuten-Zyklus des Server-Processors (leadSettings: lastRunAt/nextRunAt/
* workerBusy) + manueller Sofort-Trigger (runNow=true, der Processor pollt
* das Flag alle 5 Sekunden). */
export function useLeadCycle() {
const [cycle, setCycle] = useState(null)
const [triggering, setTriggering] = useState(false)
const fetchCycle = useCallback(async () => {
try {
const doc = await databases.getDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main')
setCycle({
lastRunAt: doc.lastRunAt || null,
nextRunAt: doc.nextRunAt || null,
runNow: Boolean(doc.runNow),
workerBusy: Boolean(doc.workerBusy),
})
} catch {
setCycle(null)
}
}, [])
useEffect(() => {
fetchCycle()
const t = setInterval(fetchCycle, 10000)
return () => clearInterval(t)
}, [fetchCycle])
const triggerNow = async () => {
setTriggering(true)
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main', {
runNow: true,
updatedAt: new Date().toISOString(),
})
await fetchCycle()
return { success: true }
} catch (err) {
return { success: false, error: err.message }
} finally {
setTriggering(false)
}
}
return { cycle, triggering, triggerNow, refresh: fetchCycle }
}
export const DEFAULT_LEAD_SETTINGS = {
branche: 'Autolackiererei',
staedte: [],
ganzDeutschland: false,
maxLeadsProStadt: 5,
minBewertung: 4.0,
emailPflicht: true,
nurSchlechteWebsite: true,
zusatzHinweise: '',
}
/** Sucheinstellungen der Routine (Appwrite `leadSettings`, Dokument `main`).
* Die Cloud-Routine liest sie vor jedem Lauf ueber den Server-Hook (/config). */
export function useLeadSettings() {
const [settings, setSettings] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const fetchSettings = useCallback(async () => {
try {
const doc = await databases.getDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main')
setSettings(doc)
setError(null)
} catch (err) {
if (err.code === 404 || err.message?.includes('not found')) {
setSettings({ ...DEFAULT_LEAD_SETTINGS })
setError(null)
} else {
setError(err.message)
}
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchSettings()
}, [fetchSettings])
const saveSettings = async (data) => {
const payload = {
branche: data.branche || '',
staedte: data.staedte || [],
ganzDeutschland: Boolean(data.ganzDeutschland),
maxLeadsProStadt: Number(data.maxLeadsProStadt) || 5,
minBewertung: Number(data.minBewertung) || 0,
emailPflicht: Boolean(data.emailPflicht),
nurSchlechteWebsite: Boolean(data.nurSchlechteWebsite),
zusatzHinweise: data.zusatzHinweise || '',
updatedAt: new Date().toISOString(),
}
try {
let doc
try {
doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main', payload)
} catch (err) {
if (err.code === 404) {
doc = await databases.createDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main', payload)
} else {
throw err
}
}
setSettings(doc)
return { success: true, data: doc }
} catch (err) {
return { success: false, error: err.message }
}
}
return { settings, loading, error, refresh: fetchSettings, saveSettings }
}
+51
View File
@@ -0,0 +1,51 @@
import { useState, useEffect } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { LIST_TABS, LIST_DEFAULTS, countFilters } from '../lib/ticketListUrl'
// Zahl je Reiter der Ticketliste: counts[tabId] ist eine Zahl, null bei Fehler,
// vor der ersten Antwort fehlt der Schluessel. Die Zahlen haengen nur an Art und "Dringend",
// nie am Reiter oder der Suche. Alte Zahlen bleiben stehen, bis die neue Antwort da ist.
function countQueries(filters) {
// Dieselben Bedingungen wie in useWorkorders, nur mit limit(1): gebraucht wird allein "total"
const queries = [Query.limit(1)]
if (filters.status && filters.status.length > 0) queries.push(Query.equal('status', filters.status))
if (filters.type && filters.type.length > 0) queries.push(Query.equal('type', filters.type))
if (filters.excludeType) queries.push(Query.notEqual('type', filters.excludeType))
if (filters.priority && filters.priority.length > 0) queries.push(Query.equal('priority', filters.priority))
return queries
}
export function useTicketCounts({ typeId, urgentOnly } = {}) {
const [counts, setCounts] = useState({})
useEffect(() => {
let stale = false
const state = { ...LIST_DEFAULTS, typeId, urgentOnly }
Promise.allSettled(
LIST_TABS.map((tab) => databases.listDocuments(
DATABASE_ID,
COLLECTIONS.WORKORDERS,
countQueries(countFilters(state, tab.id))
))
).then((results) => {
if (stale) return
const next = {}
results.forEach((result, index) => {
const tabId = LIST_TABS[index].id
if (result.status === 'fulfilled' && typeof result.value?.total === 'number') {
next[tabId] = result.value.total
} else {
next[tabId] = null
console.warn('Ticket-Zaehler nicht geladen:', tabId, result.reason)
}
})
setCounts(next)
})
return () => { stale = true }
}, [typeId, urgentOnly])
return { counts }
}
+58
View File
@@ -0,0 +1,58 @@
import { useState, useEffect, useRef } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { planTimeLoad, blockMinutes, mapLimit, TIME_PARALLEL, TIME_QUERY_LIMIT } from '../lib/ticketTimes'
// Spalte "Zeit" der Ticketliste: Arbeitszeit je woid, in Bloecken nachgeladen (nur Lesen).
// byWoid ist eine Map woid -> Minuten (null = nicht zu ermitteln). Sie wird an Ort und Stelle
// ergaenzt, `version` zaehlt hoch, wenn sich etwas geaendert hat (fuer useMemo-Abhaengigkeiten).
// Der Zwischenspeicher gilt nur fuer die Lebensdauer der Seite: wer von einer Ticketseite
// zurueckkehrt, sieht frische Zahlen.
export function useTicketMinutes(tickets) {
const cache = useRef(new Map())
const inFlight = useRef(new Set())
const mounted = useRef(true)
const [version, setVersion] = useState(0)
const [capped, setCapped] = useState(false)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false }
}, [])
useEffect(() => {
const byWoid = cache.current
const running = inFlight.current
// Ein woid, dessen Abfrage noch laeuft, gilt als bekannt (kein Block doppelt anfragen)
const known = { has: (woid) => byWoid.has(woid) || running.has(woid) }
const plan = planTimeLoad(tickets, known)
setCapped(plan.capped)
if (plan.blocks.length === 0) return
for (const block of plan.blocks) {
for (const woid of block) running.add(woid)
}
// Auch wenn sich `tickets` inzwischen geaendert hat, wird das Ergebnis eingetragen:
// sonst bliebe das woid ungeladen und ohne Abfrage.
mapLimit(plan.blocks, TIME_PARALLEL, async (block) => {
const response = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKSHEETS, [
Query.equal('woid', block),
Query.select(['woid', 'totalTime', 'supporters', 'isComment']),
Query.limit(TIME_QUERY_LIMIT),
])
return blockMinutes(block, response.documents, response.total)
}).then((results) => {
results.forEach((result, index) => {
const sums = result.ok ? result.value : null
if (!sums) console.warn('Zeitsummen nicht geladen:', result.ok ? 'Antwort abgeschnitten' : result.error)
for (const woid of plan.blocks[index]) {
byWoid.set(woid, sums ? sums[woid] ?? null : null)
running.delete(woid)
}
})
if (mounted.current) setVersion((v) => v + 1)
})
}, [tickets])
return { byWoid: cache.current, capped, version }
}
+89
View File
@@ -0,0 +1,89 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { lookupWorkorder } from '../lib/workorderLookup'
import { createRequestGuard } from '../lib/requestGuard'
// Ein Ticket zu einer Adresse wie /tickets/<ref> (Nummer oder Dokument-ID).
// Der Zustand merkt sich zu welcher Adresse er gehoert: steht schon eine neue Adresse in der
// URL, zeigt der Hook "laedt" und nie das vorige Ticket.
const LOADING = { ref: undefined, status: 'loading', ticket: null, error: null }
// Neueste Anfrage zur Nummer (woid), null wenn es keine gibt
async function findByWoid(woid) {
const response = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
Query.equal('woid', [String(woid)]),
Query.orderDesc('$createdAt'),
Query.limit(1),
])
return response.documents[0] || null
}
function getById(id) {
return databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, id)
}
export function useWorkorder(ref) {
const [state, setState] = useState(LOADING)
// Nur die juengste Anfrage darf das Ergebnis uebernehmen
const guard = useRef(createRequestGuard())
const current = state.ref === ref ? state : LOADING
const load = useCallback(async () => {
const requestId = guard.current.next()
try {
const result = await lookupWorkorder(String(ref ?? ''), { findByWoid, getById })
if (!guard.current.isCurrent(requestId)) return
setState(
result.status === 'ok'
? { ref, status: 'ok', ticket: result.ticket, error: null }
: { ref, status: 'notfound', ticket: null, error: null }
)
} catch (err) {
if (!guard.current.isCurrent(requestId)) return
let errorMessage = err.message || 'Fehler beim Laden des Tickets'
if (err.code === 401 || errorMessage.includes('not authorized') || errorMessage.includes('Unauthorized')) {
errorMessage = 'Berechtigung fehlt: Bitte überprüfe die Read-Berechtigungen der Collection in Appwrite. Die Collection muss "Users" oder "Any" als Read-Berechtigung haben.'
}
setState({ ref, status: 'error', ticket: null, error: errorMessage })
console.error('Error fetching workorder:', err)
}
}, [ref])
useEffect(() => {
load()
// Wechselt die Adresse oder verschwindet die Seite, zaehlt eine laufende Anfrage nicht mehr
const activeGuard = guard.current
return () => { activeGuard.next() }
}, [load])
const updateWorkorder = useCallback(async (id, data) => {
try {
const response = await databases.updateDocument(
DATABASE_ID,
COLLECTIONS.WORKORDERS,
id,
data
)
// Nur das geladene Ticket ersetzen (nicht, wenn die Seite inzwischen ein anderes zeigt)
setState((prev) => (
prev.status === 'ok' && prev.ticket?.$id === id ? { ...prev, ticket: response } : prev
))
return { success: true, data: response }
} catch (err) {
return { success: false, error: err.message }
}
}, [])
return {
ticket: current.ticket,
loading: current.status === 'loading',
error: current.error,
notFound: current.status === 'notfound',
updateWorkorder,
refresh: load,
}
}
+9 -3
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
import { ticketMatches, NORMAL_PRIORITY } from '../lib/ticketStatus'
import { createRequestGuard } from '../lib/requestGuard'
function buildFiltersKey(filters = {}) {
return JSON.stringify({
@@ -20,6 +21,8 @@ export function useWorkorders(filters = {}) {
const [workorders, setWorkorders] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
// Nur die juengste Anfrage darf das Ergebnis uebernehmen
const guard = useRef(createRequestGuard())
const filtersKey = useMemo(() => buildFiltersKey(filters), [
filters.limit,
filters.customerId,
@@ -34,6 +37,7 @@ export function useWorkorders(filters = {}) {
const fetchWorkorders = useCallback(async () => {
const activeFilters = JSON.parse(filtersKey)
const requestId = guard.current.next()
setLoading(true)
try {
@@ -74,7 +78,7 @@ export function useWorkorders(filters = {}) {
queries.push(Query.equal('assignedTo', activeFilters.assignedTo))
}
// WOID-Direktsuche (z. B. Deep-Link aus den Leads: /tickets?woid=123)
// WOID-Direktsuche (Altlinks aus den Leads mit woid=123 leitet TicketsPage zur Ticketseite um)
if (activeFilters.woid) {
queries.push(Query.equal('woid', [String(activeFilters.woid)]))
}
@@ -92,6 +96,7 @@ export function useWorkorders(filters = {}) {
COLLECTIONS.WORKORDERS,
queries
)
if (!guard.current.isCurrent(requestId)) return
setWorkorders(
activeFilters.search
@@ -100,6 +105,7 @@ export function useWorkorders(filters = {}) {
)
setError(null)
} catch (err) {
if (!guard.current.isCurrent(requestId)) return
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
// Bessere Fehlermeldungen
@@ -112,7 +118,7 @@ export function useWorkorders(filters = {}) {
setError(errorMessage)
console.error('Error fetching workorders:', err)
} finally {
setLoading(false)
if (guard.current.isCurrent(requestId)) setLoading(false)
}
}, [filtersKey])
+7
View File
@@ -0,0 +1,7 @@
/**
* Soll diese Taste den Dialog schließen? Nur Esc, nur wenn gerade nichts gespeichert wird (`busy`) und nur, wenn
* niemand die Taste schon behandelt hat (`defaultPrevented`, z. B. schließt eine offene Vorschlagsliste zuerst sich selbst).
*/
export function shouldCloseOnKey({ key, defaultPrevented }, busy) {
return key === 'Escape' && !defaultPrevented && !busy
}
+9
View File
@@ -0,0 +1,9 @@
import { formatDistanceToNow } from 'date-fns'
import { de } from 'date-fns/locale'
import { createdDate } from './ticketView'
// "vor 3 Tagen": dünner Wrapper über date-fns, ohne eigenen Test. Ohne gültiges Erstelldatum steht ein Strich.
export function elapsedLabel(ticket) {
const created = createdDate(ticket)
return created ? formatDistanceToNow(created, { locale: de, addSuffix: true }) : '–'
}
+3 -3
View File
@@ -1,5 +1,5 @@
// Eintraege der Sidebar als reine Daten (unter Node testbar); Navbar.jsx ordnet die Icons ueber key zu.
// Unterpunkte (children) erscheinen eingerueckt unter ihrem Eintrag, count zeigt eine Zahl (nur > 0).
// Eintraege der Sidebar als reine Daten (unter Node testbar).
// Unterpunkte (children) erscheinen eingerueckt unter ihrem Eintrag, count zeigt eine Zahl (nur > 0), hot hebt die Zahl farbig hervor.
export function buildNavLinks({ isAdmin, pulseLevel, counts = {} }) {
return [
@@ -13,7 +13,7 @@ export function buildNavLinks({ isAdmin, pulseLevel, counts = {} }) {
href: '/customers',
// Bereiche aus dem frueheren Admin-Panel; Zahlen: ungelesene Chats, offene Gutschriften, offene Speicher-Aufgaben
children: [
{ key: 'chat', label: 'Chat', href: '/customers/chat', count: counts.chat ?? 0 },
{ key: 'chat', label: 'Chat', href: '/customers/chat', count: counts.chat ?? 0, hot: true },
{ key: 'gutschriften', label: 'Gutschriften', href: '/customers/gutschriften', count: counts.credits ?? 0 },
{ key: 'angebote', label: 'Abo-Angebote', href: '/customers/angebote' },
{ key: 'speicher', label: 'E-Mail-Speicher', href: '/customers/email-speicher', count: counts.storage ?? 0 },
+45
View File
@@ -0,0 +1,45 @@
/* Planboard-Anzeigelogik: reine Texte und Klassen, die PlanboardPage braucht.
Die Terminlogik selbst (Datum lesen, nach Tag gruppieren) steckt in planboard.js. */
import { format, getISOWeek, addDays } from 'date-fns'
import { de } from 'date-fns/locale'
const NOBODY = 'niemand zugewiesen'
// "Kalenderwoche 41 · 5. Oktober bis 11. Oktober 2026 · 13 Termine"
// weekStart muss ein Montag sein (die Woche läuft bis weekStart + 6 Tage). Das Ende trägt immer das Jahr, der Anfang nur,
// wenn sein Jahr vom Ende abweicht: "28. Dezember 2026 bis 3. Januar 2027".
export function planboardSubtitle({ weekStart, count = 0, loading = false }) {
const weekEnd = addDays(weekStart, 6)
const sameYear = weekStart.getFullYear() === weekEnd.getFullYear()
const from = format(weekStart, sameYear ? 'd. MMMM' : 'd. MMMM yyyy', { locale: de })
const to = format(weekEnd, 'd. MMMM yyyy', { locale: de })
const rest = loading ? 'Wird geladen …' : `${count} ${count === 1 ? 'Termin' : 'Termine'}`
return `Kalenderwoche ${getISOWeek(weekStart)} · ${from} bis ${to} · ${rest}`
}
export function dayLabels(day) {
return {
weekday: format(day, 'EEEE', { locale: de }),
number: format(day, 'dd.MM'),
full: format(day, 'EEEE, d. MMMM yyyy', { locale: de }),
}
}
export function appointmentClass(type) {
return String(type || '').toLowerCase() === 'akquise' ? 'appt akquise' : 'appt'
}
export function appointmentTimeLabel({ time, endTime }) {
if (!time) return 'ohne Uhrzeit'
return endTime ? `${time}–${endTime}` : time
}
// Mit Ticketnummer "#100201 · KNSO", ohne Nummer nur der Mitarbeiter (kein leeres "#")
export function appointmentOwnerLabel({ woid, employeeName, employeeShort }) {
const owner = employeeName ? employeeShort || employeeName : NOBODY
return woid ? `#${woid} · ${owner}` : owner
}
export function appointmentTitle({ woid }) {
return woid ? `Ticket #${woid} öffnen` : 'Ticket öffnen'
}
+14
View File
@@ -0,0 +1,14 @@
/* Nur die juengste Anfrage darf das Ergebnis uebernehmen (spaete, aeltere Antworten werden verworfen) */
export function createRequestGuard() {
let current = 0
return {
next() {
current += 1
return current
},
isCurrent(id) {
return id === current
},
}
}
+13
View File
@@ -0,0 +1,13 @@
/* Klickbare Tabellenzeilen: nur ein einfacher Linksklick auf die Zeile selbst oeffnet sie.
Klicks auf Links, Knoepfe und Formularfelder in der Zeile, Mittelklick und Klicks mit
Modifikatortaste (neuer Tab, Auswahl) bleiben Sache des Browsers bzw. des Elements. */
const INTERACTIVE = 'a,button,select,input,textarea,label'
export function isRowActivation(event) {
if (event.defaultPrevented || event.button !== 0) return false
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
const hit = event.target?.closest?.(INTERACTIVE)
// Ein interaktiver Vorfahre ausserhalb der Zeile zaehlt nicht
return !(hit && event.currentTarget?.contains?.(hit))
}
-12
View File
@@ -1,12 +0,0 @@
// Seitenmenue: auf schmalen Bildschirmen (Handy) startet es eingeklappt, sonst bleiben vom Inhalt bei 390 px nur
// etwa 140 px und jede Seite scrollt waagerecht. Ausklappen geht weiterhin per Knopf.
export const AUTO_COLLAPSE_MAX_WIDTH = 720
/** true, wenn das Menue eingeklappt starten soll (Fensterbreite bis 720 px); ohne window (Test, Server) false. */
export function startsCollapsed(win = typeof window !== 'undefined' ? window : null) {
try {
return Boolean(win?.matchMedia?.(`(max-width: ${AUTO_COLLAPSE_MAX_WIDTH}px)`)?.matches)
} catch {
return false
}
}
+85
View File
@@ -0,0 +1,85 @@
/* Zustand der Ticketliste in der Adresse: status (Reiter-Id), art (Id aus TYPE_FILTERS),
dringend (1), q, limit - in dieser Reihenfolge, Standardwerte werden nicht geschrieben.
Aus dem Zustand entstehen die Filter fuer Liste und Zaehler (beide muessen dieselben nutzen). */
import { STATUS_GROUPS, DEFAULT_STATUS_GROUPS, statusValuesFor, TYPE_FILTERS } from './ticketStatus'
import { ticketPath } from './ticketRoutes'
// Reiter: "Aktiv" (alles Laufende), je eine Statusgruppe, zuletzt "Alle" (groups: null = kein Statusfilter)
export const LIST_TABS = [
{ id: 'aktiv', label: 'Aktiv', groups: DEFAULT_STATUS_GROUPS },
...STATUS_GROUPS.map((group) => ({ id: group.id, label: group.label, groups: [group.id] })),
{ id: 'alle', label: 'Alle', groups: null },
]
export const LIST_DEFAULTS = { tab: 'aktiv', typeId: 'orders', urgentOnly: false, search: '', limit: 10 }
const MIN_LIMIT = 10
const MAX_LIMIT = 500
const LIMIT_STEP = 10
// Hoch (3) und Kritisch (4) gelten als dringend (siehe ticketStatus.js)
const URGENT_PRIORITIES = [3, 4]
export function clampLimit(value) {
const n = Math.floor(Number(value))
if (!Number.isFinite(n)) return MIN_LIMIT
return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, n))
}
// input: URLSearchParams oder String ("?status=done" und "status=done" gehen beide)
export function parseListParams(input) {
const params = new URLSearchParams(input)
const tab = params.get('status')
const typeId = params.get('art')
const limit = params.get('limit')
return {
tab: LIST_TABS.some((t) => t.id === tab) ? tab : LIST_DEFAULTS.tab,
typeId: TYPE_FILTERS.some((f) => f.id === typeId) ? typeId : LIST_DEFAULTS.typeId,
urgentOnly: params.get('dringend') === '1',
search: (params.get('q') || '').trim(),
limit: limit !== null && /^\d+$/.test(limit) ? clampLimit(limit) : LIST_DEFAULTS.limit,
}
}
export function serializeListParams(state) {
const params = new URLSearchParams()
if (state.tab !== LIST_DEFAULTS.tab) params.set('status', state.tab)
if (state.typeId !== LIST_DEFAULTS.typeId) params.set('art', state.typeId)
if (state.urgentOnly) params.set('dringend', '1')
if (state.search) params.set('q', state.search)
const limit = clampLimit(state.limit)
if (limit !== LIST_DEFAULTS.limit) params.set('limit', String(limit))
return params
}
// Jede Filteraenderung beginnt wieder bei der ersten Seite
export function withFilter(state, patch) {
return { ...state, ...patch, limit: LIST_DEFAULTS.limit }
}
// "Mehr laden"
export function nextLimit(state) {
return clampLimit(Number(state.limit) + LIMIT_STEP)
}
// Filter fuer den Zaehler eines Reiters: wie die Liste, aber ohne Suche und limit
export function countFilters(state, tabId) {
const tab = LIST_TABS.find((t) => t.id === tabId) || LIST_TABS[0]
const type = TYPE_FILTERS.find((f) => f.id === state.typeId) || TYPE_FILTERS[0]
return {
status: tab.groups === null ? [] : statusValuesFor(tab.groups),
type: [...(type.types || [])],
excludeType: type.excludeType,
priority: state.urgentOnly ? [...URGENT_PRIORITIES] : [],
}
}
export function listFilters(state) {
return { ...countFilters(state, state.tab), search: state.search || undefined, limit: state.limit }
}
// Altlinks aus den Leads (Liste mit Suchteil woid=100131) fuehren zur Ticketseite und haben Vorrang vor allen Filtern
export function woidRedirectTarget(input) {
const woid = (new URLSearchParams(input).get('woid') || '').trim()
return woid ? ticketPath(woid) : null
}
+28
View File
@@ -0,0 +1,28 @@
/* Adressen der Ticketseite: /tickets/<woid oder $id> mit dem Reiter Details (ohne Zusatz)
oder Arbeitsblaetter. Alle Links auf Tickets entstehen hier. */
// Reiter der Ticketseite; "details" hat keinen eigenen Pfadteil
export const TICKET_TABS = ['details', 'arbeitsblaetter']
// Das Ticket wird ueber seine woid adressiert, sonst ueber die $id (nie ueber die Kurznummer)
export function ticketRef(ticket) {
return String(ticket?.woid ?? '').trim() || String(ticket?.$id ?? '')
}
export function ticketPath(ref, tab) {
const id = String(ref ?? '').trim()
if (!id) return '/tickets'
return '/tickets/' + encodeURIComponent(id) + (tab === 'arbeitsblaetter' ? '/arbeitsblaetter' : '')
}
// Reiter aus der Adresse: fehlt er, gilt "details", ein unbekannter Wert ergibt null
export function resolveTicketTab(tab) {
if (tab === undefined) return 'details'
return TICKET_TABS.includes(tab) ? tab : null
}
// Zurueck zur Liste mit dem gemerkten Suchteil (nur "?..." mit Inhalt, sonst kein Fremdtext in der Adresse)
export function listBackPath(locationState) {
const search = locationState?.listSearch
return typeof search === 'string' && search.startsWith('?') && search.length > 1 ? '/tickets' + search : '/tickets'
}
+22 -15
View File
@@ -32,6 +32,14 @@ export function statusLabel(status) {
return LABELS[status] || status
}
// Auswahl fuer das Status-Feld: die sechs waehlbaren Werte, ein alter Wert des Tickets bleibt zusaetzlich sichtbar
export function statusSelectOptions(current) {
const options = TICKET_STATUSES.map((s) => ({ ...s }))
const value = current || 'Open'
if (!options.some((o) => o.value === value)) options.push({ value, label: statusLabel(value) })
return options
}
// Filter: jede Gruppe fasst die gespeicherten Werte zusammen, auch die alten
export const STATUS_GROUPS = [
{ id: 'open', label: 'Offen', values: ['Open'] },
@@ -49,24 +57,23 @@ export function statusGroup(status) {
return STATUS_GROUPS.find((group) => group.values.includes(status))?.id || null
}
const GROUP_TONE = {
open: '',
working: 'blue',
waiting: 'orange',
review: 'violet',
done: 'green',
cancelled: 'plain',
}
export function statusTone(status) {
return GROUP_TONE[statusGroup(status)] || ''
}
export function statusValuesFor(groupIds) {
return STATUS_GROUPS.filter((group) => groupIds.includes(group.id)).flatMap((group) => group.values)
}
const GROUP_CLASS = {
open: 'status-open',
working: 'status-working',
waiting: 'status-awaiting',
review: 'status-assigned',
done: 'status-closed',
cancelled: 'status-closed',
}
// CSS-Klasse fuer das Status-Abzeichen
export function statusClass(status) {
return GROUP_CLASS[statusGroup(status || 'Open')] || 'status-open'
}
// Statt fuenf Prioritaeten gibt es nur noch "dringend" oder nicht.
// Frueher gespeicherte Stufen: Hoch (3) und Kritisch (4) gelten als dringend.
export const NORMAL_PRIORITY = 2
@@ -93,7 +100,7 @@ export const TYPE_FILTERS = [
{ id: 'Migration', label: 'Migration', types: ['Migration'] },
{ id: 'Project', label: 'Projekt', types: ['Project'] },
{ id: 'Akquise', label: 'Akquise', types: ['Akquise'] },
{ id: 'all', label: 'Alle' },
{ id: 'all', label: 'Aufträge und Akquise' },
]
// Suche in Kunde, Betreff, "Angefragt von" und WOID, ohne Gross-/Kleinschreibung
+74
View File
@@ -0,0 +1,74 @@
/* Spalte "Zeit" der Ticketliste: Arbeitszeit je Ticket, in Bloecken nachgeladen.
Nur Lesezugriffe. Eine Zelle ohne Ergebnis (laedt noch, Fehler, 0 min, abgeschnitten,
jenseits der ersten 200 Zeilen) zeigt immer "–", nie eine falsche Zahl. */
import { sumWorksheetMinutes } from './worksheetView'
import { stepLabel } from './timeSteps'
export const TIME_BLOCK = 100 // woids je Abfrage
export const TIME_QUERY_LIMIT = 5000 // Dokumente je Abfrage
export const TIME_MAX_ROWS = 200 // nur die ersten Zeilen bekommen eine Zeit
export const TIME_PARALLEL = 3 // gleichzeitige Abfragen
export function chunk(list, size) {
const out = []
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size))
return out
}
// Welche woids sind noch zu laden? Erste 200 Zeilen, ohne leere, doppelte und schon bekannte.
export function planTimeLoad(tickets, known = new Set()) {
const list = tickets || []
const seen = new Set()
const woids = []
for (const ticket of list.slice(0, TIME_MAX_ROWS)) {
const woid = String(ticket?.woid ?? '').trim()
if (!woid || seen.has(woid) || known.has(woid)) continue
seen.add(woid)
woids.push(woid)
}
return { blocks: chunk(woids, TIME_BLOCK), capped: list.length > TIME_MAX_ROWS }
}
// Minuten je woid eines Blocks. Wurde die Antwort abgeschnitten (Limit erreicht oder
// mehr Treffer als geliefert), ist keine Summe verlaesslich: null.
export function blockMinutes(woids, documents, total, limit = TIME_QUERY_LIMIT) {
const docs = documents || []
if (docs.length >= limit) return null
if (typeof total === 'number' && total > docs.length) return null
const byWoid = new Map()
for (const doc of docs) {
const key = String(doc?.woid ?? '').trim()
if (!byWoid.has(key)) byWoid.set(key, [])
byWoid.get(key).push(doc)
}
return Object.fromEntries(woids.map((woid) => [woid, sumWorksheetMinutes(byWoid.get(woid) || [])]))
}
// Worker-Pool: hoechstens `limit` gleichzeitig, Ergebnisse in Eingabereihenfolge, verwirft nie
export async function mapLimit(items, limit, fn) {
const results = new Array(items.length)
let next = 0
const worker = async () => {
while (next < items.length) {
const index = next
next += 1
try {
results[index] = { ok: true, value: await fn(items[index], index) }
} catch (error) {
results[index] = { ok: false, error }
}
}
}
await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker))
return results
}
export function minutesCellText(ticket, index, byWoid) {
if (index >= TIME_MAX_ROWS) return '–'
const woid = String(ticket?.woid ?? '').trim()
if (!woid) return '–'
const minutes = byWoid?.get(woid)
if (typeof minutes !== 'number' || !(minutes > 0)) return '–'
return stepLabel(minutes)
}
+77
View File
@@ -0,0 +1,77 @@
/* Anzeige-Helfer der Ticketseite und -liste.
Termin- und Faelligkeitsfelder sind Freitext aus Altdaten ("2.5.2026", "0930"): lesbare Werte
erscheinen normalisiert, alles andere als Rohtext - nie ein Rueckfall auf das Erstellungsdatum. */
import { formatTime } from './planboard'
import { isoToGermanDate, germanDateToIso } from './ticketForm'
const text = (value) => String(value ?? '').trim()
// "2.5.2026" -> "02.05.2026"; Unlesbares ("31.02.2026", ISO-Zeitstempel) bleibt als Text stehen
export function displayDate(value) {
return isoToGermanDate(germanDateToIso(value)) || text(value)
}
// "0930" oder "9:30" -> "09:30"; Unlesbares ("2560") bleibt als Text stehen
export function displayTime(value) {
return formatTime(value) || text(value)
}
// "02.05.2026, 09:30–10:30"; ohne Startdatum leer, das Ende steht nur mit Beginn
export function appointmentText(ticket) {
if (!text(ticket?.startDate)) return ''
let out = displayDate(ticket.startDate)
const start = displayTime(ticket.startTime)
if (start) {
out += ', ' + start
const end = displayTime(ticket.endTime)
if (end) out += '–' + end
}
return out
}
// "Faellig" nutzt nur die Frist, nie die Uhrzeit des Termins
export function dueText(ticket) {
return displayDate(ticket?.deadline)
}
// Nummer: woid, sonst die letzten fuenf Zeichen der $id (wie bisher in der Karte)
export function ticketNumber(ticket) {
return text(ticket?.woid) || text(ticket?.$id).slice(-5)
}
// Erstellzeitpunkt als Date, null wenn fehlt oder kaputt
export function createdDate(ticket) {
const raw = ticket?.$createdAt || ticket?.createdAt
if (!raw) return null
const date = new Date(raw)
return Number.isNaN(date.getTime()) ? null : date
}
// "Kenso Grimm" -> "Kenso G.", "Anna Maria Meier" -> "Anna M."
export function shortName(name) {
const words = text(name).split(/\s+/).filter(Boolean)
if (words.length < 2) return words[0] || ''
return `${words[0]} ${Array.from(words[words.length - 1])[0]}.`
}
// Zustaendig in der Liste: Person aus der Mitarbeiterliste, sonst der am Ticket gespeicherte Name
export function assigneeLabel(ticket, employees = []) {
const id = text(ticket?.assignedTo)
if (!id) return { text: 'Niemand', none: true }
const employee = employees.find((e) => e.userId === id)
return { text: shortName(employee?.displayName) || shortName(ticket.assignedName) || 'Unbekannt', none: false }
}
// Auswahl "Zustaendig": der aktuelle Wert bleibt immer waehlbar, auch wenn die Person nicht (mehr) in der Liste steht
export function assigneeOptions(ticket, employees = []) {
const options = [{ value: '', label: 'Nicht zugewiesen' }]
for (const e of employees) {
if (e.userId) options.push({ value: e.userId, label: e.displayName || e.email || e.userId })
}
const id = text(ticket?.assignedTo)
if (id && !options.some((o) => o.value === id)) {
options.push({ value: id, label: text(ticket.assignedName) || 'Unbekannte Person' })
}
return options
}
+21
View File
@@ -0,0 +1,21 @@
/* Ticket zu einer Adresse wie /tickets/<ref>: erst die Nummer (woid), dann die Dokument-ID.
Kennt kein Appwrite, die Seite reicht die Abfragen herein. */
const NOT_FOUND_CODES = [400, 404]
export async function lookupWorkorder(ref, { findByWoid, getById }) {
const key = String(ref ?? '').trim()
if (!key) return { status: 'notfound' }
const byWoid = await findByWoid(key)
if (byWoid) return { status: 'ok', ticket: byWoid }
try {
const byId = await getById(key)
return byId ? { status: 'ok', ticket: byId } : { status: 'notfound' }
} catch (error) {
// Appwrite: 404 = keine solche ID, 400 = keine gueltige ID
if (NOT_FOUND_CODES.includes(Number(error?.code))) return { status: 'notfound' }
throw error
}
}
+32
View File
@@ -0,0 +1,32 @@
/* Folgen eines neuen Arbeitsblatts.
Kennt kein Appwrite: die Seite reicht die Schreibzugriffe herein. */
// Akquise-Automatik: das Ergebnis im Arbeitsblatt steuert den Kundenstatus.
// "customer" = Zugesagt, "lost" = Abgesagt, sonst keine Aenderung.
export function acquisitionOutcome(ticket, newStatus) {
if (ticket?.type !== 'Akquise' || !ticket.customerId) return null
if (newStatus === 'Zugesagt') return 'customer'
if (newStatus === 'Abgesagt') return 'lost'
return null
}
/* 1. Arbeitsblatt anlegen. 2. Bei Erfolg den Ticket-Status nachziehen, wenn er abweicht.
3. Bei Akquise-Tickets den Kundenstatus setzen (Fehler werden verschluckt).
Rueckgabe ist das Ergebnis von createWorksheet. */
export async function createWorksheetFlow({ ticket, worksheetData, user, createWorksheet, updateTicket, updateCustomer, now = () => new Date() }) {
const result = await createWorksheet(worksheetData, user)
if (!result?.success) return result
const newStatus = worksheetData?.newStatus
if (newStatus !== ticket.status) {
await updateTicket(ticket.$id, { status: newStatus })
}
const customerStatus = acquisitionOutcome(ticket, newStatus)
if (customerStatus) {
try {
await updateCustomer(ticket.customerId, { customerStatus, updatedAt: now().toISOString() })
} catch { /* nicht kritisch */ }
}
return result
}
+75
View File
@@ -0,0 +1,75 @@
/* Anzeige- und Rechenlogik der Arbeitsblatt-Zeilen und der Kennzahlen darueber.
Kommentare haben keine Arbeitszeit und zaehlen nie mit. Die Zeit in der Zeile ist die
gespeicherte Arbeitszeit (totalTime); die Kennzahlen rechnen die Unterstuetzung mit. */
import { worksheetMinutes, supportMinutes, parseSupporters, firstName } from './support'
import { needsTimeEntry, worksheetTone, isGitWorksheet, serviceTypeLabel } from './ticketForm'
import { statusLabel } from './ticketStatus'
import { stepLabel } from './timeSteps'
import { displayDate, displayTime } from './ticketView'
// Summe der Arbeitszeit inklusive Unterstuetzung ueber alle Bloecke ohne Kommentare, optional gefiltert.
// Dieselbe Funktion rechnet die Spalte "Zeit" der Ticketliste.
export function sumWorksheetMinutes(worksheets, predicate = () => true) {
return (worksheets || [])
.filter((ws) => !ws.isComment && predicate(ws))
.reduce((sum, ws) => sum + worksheetMinutes(ws), 0)
}
// Kennzahlen ueber der Liste: Arbeitszeit gesamt, davon Abo-Stunden, Anzahl offener Git-Zeiten
export function worksheetSums(worksheets) {
const list = worksheets || []
return {
totalMinutes: sumWorksheetMinutes(list),
aboMinutes: sumWorksheetMinutes(list, (ws) => Boolean(ws.countsToPlan)),
openCount: list.filter(needsTimeEntry).length,
}
}
// Regel fuer den Abo-Schalter einer Zeile; ob die Seite ihn ueberhaupt anbietet (onTogglePlan), prueft die Komponente
export function showAboToggle(ws, aboHoursAllowed) {
return Boolean(!ws.isComment && Number(ws.totalTime) > 0 && (aboHoursAllowed || ws.countsToPlan))
}
// Wann: Startzweig, wenn ein Startdatum gesetzt ist, sonst der Endzweig. Altwerte bleiben Rohtext.
export function worksheetWhen(ws) {
const start = String(ws?.startDate ?? '').trim() !== ''
return start
? { date: displayDate(ws.startDate), time: displayTime(ws.startTime) }
: { date: displayDate(ws?.endDate), time: displayTime(ws?.endTime) }
}
export function worksheetKindLabel(ws) {
if (ws?.isComment && !isGitWorksheet(ws)) return 'Kommentar'
return serviceTypeLabel(ws?.serviceType) || ''
}
// Git-Pushes: orange, bis die Zeit nachgetragen ist, danach gruen; sonst kein Abzeichen
export function worksheetBadge(ws) {
const tone = worksheetTone(ws)
if (tone === 'git-open') return { tone: 'orange', text: 'Zeit offen' }
if (tone === 'git-done') return { tone: 'green', text: 'Zeit eingetragen' }
return null
}
// Nur wenn sich der Status geaendert hat
export function statusAfterLabel(ws) {
const next = ws?.newStatus
if (!next || next === ws.oldStatus) return ''
return `Status danach: ${statusLabel(next)}`
}
// Je unterstuetzender Person eine Zeile, z. B. "Unterstützung Anna · DNS · 30 min"
export function supportLines(ws) {
if (!ws || ws.isComment) return []
const minutes = stepLabel(supportMinutes(ws.totalTime))
return parseSupporters(ws.supporters).map((sup) =>
`${['Unterstützung', firstName(sup.name)].filter(Boolean).join(' ')} · ${sup.task} · ${minutes}`)
}
// Zeit rechts in der Zeile: Kommentare leer, ohne Arbeitszeit ein Strich
export function timeLabel(ws) {
if (ws?.isComment) return ''
const minutes = Number(ws?.totalTime) || 0
return minutes <= 0 ? '–' : stepLabel(minutes)
}
+29 -27
View File
@@ -1,7 +1,8 @@
.admin-page {
--admin-line: rgba(255, 255, 255, 0.1);
--admin-line-strong: rgba(255, 255, 255, 0.17);
/* .page (global.css, wird danach geladen) gewinnt bei gleicher Spezifitaet: darum .page.admin-page */
.page.admin-page {
max-width: 1480px;
}
.admin-page {
padding-bottom: 48px;
}
@@ -144,15 +145,15 @@
}
.admin-icon-button:hover {
border-color: rgba(16, 185, 129, 0.5);
border-color: var(--accent-ring);
color: var(--text);
background: rgba(16, 185, 129, 0.07);
background: var(--accent-soft);
}
.admin-icon-button.danger:hover {
border-color: rgba(239, 68, 68, 0.55);
color: #fca5a5;
background: rgba(239, 68, 68, 0.08);
border-color: var(--danger);
color: var(--red-fg);
background: var(--red-bg);
}
.admin-primary-button {
@@ -162,17 +163,17 @@
justify-content: center;
gap: 8px;
padding: 9px 15px;
border: 1px solid #34d399;
border: 1px solid var(--accent);
border-radius: 6px;
background: var(--accent);
color: #05251c;
color: #fff;
font: inherit;
font-size: 13px;
font-weight: 750;
cursor: pointer;
}
.admin-primary-button:hover { background: #34d399; }
.admin-primary-button:hover { background: var(--accent-hover); }
.admin-primary-button:disabled { opacity: 0.55; cursor: wait; }
.admin-split-layout {
@@ -261,14 +262,14 @@
.admin-table td {
padding: 12px 13px;
border-bottom: 1px solid rgba(255, 255, 255, 0.065);
border-bottom: 1px solid var(--line);
color: var(--text);
font-size: 13px;
vertical-align: middle;
}
.admin-table tbody tr:last-child td { border-bottom: 0; }
.admin-table tbody tr:hover td { background: rgba(255, 255, 255, 0.018); }
.admin-table tbody tr:hover td { background: var(--bg-subtle); }
.admin-table td > strong,
.admin-table td > span { display: block; }
.admin-table td > span { margin-top: 3px; color: var(--text-muted); font-size: 12px; }
@@ -279,29 +280,30 @@
gap: 6px;
}
.admin-status,
.admin-role {
display: inline-flex !important;
/* Tabellenzellen: .admin-table td > span (0,1,2) setzt display/Farbe/Schrift; die Selektoren hier sind spezifischer (0,2,1) */
.admin-table td > .admin-status,
.admin-table td > .admin-role {
display: inline-flex;
width: fit-content;
align-items: center;
padding: 3px 7px;
border: 1px solid var(--admin-line-strong);
border-radius: 4px;
color: var(--text-muted) !important;
font-size: 11px !important;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
}
.admin-status.active,
.admin-role.admin {
border-color: rgba(16, 185, 129, 0.45);
color: #6ee7b7 !important;
.admin-table td > .admin-status.active,
.admin-table td > .admin-role.admin {
border-color: var(--green-fg);
color: var(--green-fg);
}
.admin-shortcode {
color: var(--text) !important;
.admin-table td > .admin-shortcode {
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px !important;
font-size: 12px;
letter-spacing: 0.04em;
}
@@ -352,8 +354,8 @@
align-items: center;
margin-bottom: 16px;
padding: 13px 15px;
border: 1px solid rgba(16, 185, 129, 0.42);
background: rgba(16, 185, 129, 0.065);
border: 1px solid var(--line-strong);
background: var(--bg-subtle);
}
.admin-credential-result strong,
+4 -4
View File
@@ -51,13 +51,13 @@ export default function AdminPage() {
if (!isAdmin) {
return (
<main className="page admin-page">
<div className="page admin-page">
<section className="admin-access-denied">
<span>403</span>
<h1>Zugriff verweigert</h1>
<p>Für diesen Bereich ist ein Admin-Account erforderlich.</p>
</section>
</main>
</div>
)
}
@@ -121,7 +121,7 @@ export default function AdminPage() {
}
return (
<main className="page admin-page">
<div className="page admin-page">
<header className="admin-header">
<div>
<span className="admin-eyebrow">Systemverwaltung</span>
@@ -222,6 +222,6 @@ export default function AdminPage() {
</div>
</section>
)}
</main>
</div>
)
}
+3 -2
View File
@@ -8,6 +8,7 @@ import { fetchServices, completeAssignment, fetchProject } from '../lib/projectK
import { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } from '../lib/assignFlow'
import { suggestSubdomain, slugify } from '../lib/subdomain'
import { isArchived } from '../lib/leads'
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
import { PageHeader, Card, Button, Field } from '../components/ui'
import { CreateCustomerForm } from './CustomersPage'
@@ -153,7 +154,7 @@ export default function AssignProjectPage() {
if (result.ticketId) {
try {
const t = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, result.ticketId)
path = `/tickets?woid=${encodeURIComponent(t.woid)}`
path = ticketPath(ticketRef(t))
} catch {
path = '/tickets'
}
@@ -402,5 +403,5 @@ function TicketLink({ ticketId }) {
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
return woid ? <Link to={ticketPath(woid)}>Zum Ticket #{woid}</Link> : null
}
-614
View File
@@ -1,614 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import {
FaSpinner,
FaStar,
FaGear,
FaPlus,
FaXmark,
FaMapLocationDot,
FaGlobe,
FaPhone,
FaEnvelope,
FaChevronDown,
FaChevronUp,
FaGitAlt,
FaFloppyDisk,
FaArrowsRotate,
FaCheck,
FaCopy,
FaEye,
FaHashtag,
FaTriangleExclamation,
} from 'react-icons/fa6'
import { useLeads, useLeadSettings, useLeadCycle } from '../hooks/useLeads'
import { PageHeader, Card, Button, Badge, EmptyState, Field, Kpi } from '../components/ui'
import CopyableCredential from '../components/CopyableCredential'
// Schritte der Server-Pipeline (processor.py) — Reihenfolge = pipelineStep 1..5
const PIPELINE_STEPS = [
'Lead erstellt',
'Kunde & Repo',
'Daten eingesetzt',
'Personalisiert',
'E-Mail erstellt',
]
const pulseStyle = `
@keyframes leadPulse { 0% { opacity: .35 } 50% { opacity: 1 } 100% { opacity: .35 } }
`
function scoreColor(score) {
if (score >= 85) return '#34d399'
if (score >= 70) return '#fbbf24'
return '#f87171'
}
/** Balken ganz oben: laeuft von Minute 0 bis 5 (lastRunAt -> nextRunAt) und
* zeigt, wann der Server das naechste Mal nach neuen Leads schaut. Rechts
* ein Refresh-Icon, das den Lauf sofort ausloest (leadSettings.runNow). */
function CycleBar() {
const { cycle, triggering, triggerNow } = useLeadCycle()
const [now, setNow] = useState(Date.now())
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(t)
}, [])
const last = cycle?.lastRunAt ? new Date(cycle.lastRunAt).getTime() : null
const next = cycle?.nextRunAt ? new Date(cycle.nextRunAt).getTime() : null
const active = Boolean(cycle?.workerBusy || cycle?.runNow || triggering)
let pct = 0
let label = 'Automatik-Status unbekannt'
if (active) {
pct = 100
label = 'Pipeline läuft — Leads werden verarbeitet…'
} else if (last && next && next > last) {
pct = Math.min(100, Math.max(0, ((now - last) / (next - last)) * 100))
const rest = Math.max(0, next - now)
const m = Math.floor(rest / 60000)
const s = Math.floor((rest % 60000) / 1000)
label = rest > 0
? `Nächste Prüfung auf neue Leads in ${m}:${String(s).padStart(2, '0')} Min.`
: 'Prüfung startet gleich…'
}
return (
<div className="ui-card pad" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 14 }}>
<div className="grow" style={{ minWidth: 0 }}>
<div className="flex" style={{ justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>Lead-Automatik (alle 5 Minuten)</div>
<div className="muted" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{label}</div>
</div>
<div style={{ height: 8, borderRadius: 4, background: 'var(--surface-2)', overflow: 'hidden', marginTop: 6 }}>
<div
style={{
width: `${pct}%`,
height: '100%',
background: active ? '#3b82f6' : 'var(--accent, #3b82f6)',
transition: 'width 1s linear',
animation: active ? 'leadPulse 1.2s ease-in-out infinite' : 'none',
}}
/>
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={triggerNow}
disabled={active}
title="Jetzt sofort auf neue Leads prüfen und verarbeiten"
aria-label="Pipeline jetzt starten"
>
<FaArrowsRotate className={active ? 'spinner' : ''} />
</Button>
</div>
)
}
export default function LeadsPage() {
const { leads, pipelines, loading, error } = useLeads()
const [search, setSearch] = useState('')
const [showSettings, setShowSettings] = useState(false)
const filtered = useMemo(() => {
return (leads || [])
.filter((l) => {
if (!search) return true
const s = search.toLowerCase()
return `${l.firma} ${l.ort || ''} ${l.email || ''} ${l.branche || ''}`.toLowerCase().includes(s)
})
}, [leads, search])
const kpis = useMemo(() => {
const all = leads || []
const avg = all.length
? Math.round(all.reduce((sum, l) => sum + (l.leadScore || 0), 0) / all.length)
: 0
const orte = new Set(all.map((l) => (l.ort || '').trim().toLowerCase()).filter(Boolean))
return { total: all.length, avg, orte: orte.size }
}, [leads])
return (
<div className="page">
<style>{pulseStyle}</style>
<PageHeader
title="Leads"
subtitle="Recherche-Ergebnisse der täglichen 06:00-Routine — hier nur Anzeige & Verfolgung, gearbeitet wird im Akquise-Ticket"
actions={
<Button variant={showSettings ? 'primary' : 'default'} onClick={() => setShowSettings((s) => !s)}>
<FaGear /> Sucheinstellungen
</Button>
}
/>
<CycleBar />
{showSettings && (
<div style={{ marginBottom: 16 }}>
<LeadSettingsCard onClose={() => setShowSettings(false)} />
</div>
)}
<div className="kpi-grid" style={{ marginBottom: 16 }}>
<Kpi label="Leads gesamt" value={kpis.total} />
<Kpi label="Ø Lead-Score" value={kpis.avg} accent="#f59e0b" />
<Kpi label="Orte" value={kpis.orte} accent="#8b5cf6" />
</div>
<Card pad={false}>
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
<input
className="form-control search-input"
placeholder="Lead suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ maxWidth: 320 }}
/>
</div>
<div className="ui-card-body">
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : error ? (
<EmptyState title="Fehler beim Laden" hint={error} />
) : filtered.length === 0 ? (
<EmptyState
title="Keine Leads"
hint="Die 06:00-Routine liefert neue Leads automatisch hierher."
/>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 14,
}}
>
{filtered.map((lead) => (
<LeadCard key={lead.$id} lead={lead} pipeline={pipelines[lead.leadId]} />
))}
</div>
)}
</div>
</Card>
</div>
)
}
/** Verfolgbarer Pipeline-Balken (5 Segmente) — zeigt pro Lead, welcher
* Automatik-Schritt erledigt ist, gerade laeuft oder fehlgeschlagen ist. */
function PipelineBar({ lead }) {
const step = Number.isInteger(lead.pipelineStep) ? lead.pipelineStep : null
if (step === null) return null
const status = lead.pipelineStatus || 'idle'
let label
if (status === 'done') label = 'Automatisch verarbeitet ✓'
else if (status === 'running') label = `${PIPELINE_STEPS[step] || 'Verarbeitung'} läuft…`
else if (status === 'pending') label = 'Wartet auf den nächsten Lauf'
else if (status === 'failed') label = `Fehler: ${PIPELINE_STEPS[step] || '?'}`
else if (status === 'waiting_enrichment') label = 'Wartet auf Anreicherung (Bilder & Logo)'
else label = step > 0 ? `Stand: ${PIPELINE_STEPS[step - 1]} (manuell)` : 'Keine Automatik'
return (
<div>
<div style={{ display: 'flex', gap: 3 }}>
{PIPELINE_STEPS.map((name, i) => {
const n = i + 1
let background = 'var(--surface-2)'
let animation = 'none'
if (n <= step) background = '#34d399'
if (status === 'running' && n === step + 1) {
background = '#3b82f6'
animation = 'leadPulse 1.2s ease-in-out infinite'
}
if (status === 'failed' && n === step + 1) background = '#f87171'
if (status === 'waiting_enrichment' && n === step + 1) background = '#fbbf24'
if (status === 'idle' && n <= step) background = '#9ca3af'
return (
<div
key={name}
title={`${n}. ${name}`}
style={{ flex: 1, height: 6, borderRadius: 3, background, animation }}
/>
)
})}
</div>
<div
className={status === 'failed' ? 'text-red' : 'faint'}
title={status === 'failed' ? lead.pipelineError || '' : label}
style={{ fontSize: 11, marginTop: 3, display: 'flex', alignItems: 'center', gap: 4 }}
>
{status === 'failed' && <FaTriangleExclamation />}
{label} · {Math.min(step, PIPELINE_STEPS.length)}/{PIPELINE_STEPS.length}
</div>
</div>
)
}
/** Schritt-Log aus leadPipeline.steps (JSON) — im aufgeklappten Bereich. */
function PipelineLog({ pipeline }) {
const steps = useMemo(() => {
try {
const parsed = JSON.parse(pipeline?.steps || '[]')
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}, [pipeline])
if (steps.length === 0) return null
return (
<div>
<strong>Pipeline-Verlauf:</strong>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, marginTop: 4 }}>
{steps.map((s) => (
<div key={s.n} className="flex gap-2" style={{ alignItems: 'baseline', fontSize: 12 }}>
<span style={{ color: s.ok ? '#34d399' : '#f87171', flexShrink: 0 }}>
{s.ok ? <FaCheck /> : <FaXmark />}
</span>
<span style={{ flexShrink: 0 }}>{s.n}. {s.name}</span>
<span className="faint" style={{ flexShrink: 0 }}>
{s.at ? new Date(s.at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
{s.hint && <span className="muted">— {s.hint}</span>}
</div>
))}
</div>
</div>
)
}
/** Der von der Pipeline geschriebene E-Mail-Entwurf, mit Kopieren-Button. */
function EmailDraft({ pipeline }) {
const [copied, setCopied] = useState(false)
if (!pipeline?.emailText) return null
const copy = async () => {
try {
await navigator.clipboard.writeText(
`Betreff: ${pipeline.emailBetreff || ''}\n\n${pipeline.emailText}`
)
setCopied(true)
setTimeout(() => setCopied(false), 1600)
} catch { /* Clipboard nicht verfuegbar */ }
}
return (
<div>
<div className="flex gap-2" style={{ alignItems: 'center' }}>
<strong>E-Mail-Entwurf:</strong>
<Button size="sm" variant="ghost" onClick={copy} title="E-Mail in die Zwischenablage kopieren">
{copied ? <FaCheck /> : <FaCopy />}
</Button>
</div>
{pipeline.emailBetreff && (
<div style={{ fontWeight: 600, fontSize: 12, marginTop: 4 }}>{pipeline.emailBetreff}</div>
)}
<div
className="muted"
style={{ whiteSpace: 'pre-wrap', fontSize: 12, marginTop: 4, maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 6, padding: 8 }}
>
{pipeline.emailText}
</div>
</div>
)
}
/** Reine Info-Anzeige — Status/Notizen werden NICHT hier gepflegt,
* sondern im Akquise-Ticket des Leads (WOID-Chip). */
function LeadCard({ lead, pipeline }) {
const [open, setOpen] = useState(false)
const score = lead.leadScore || 0
const websiteUrl = /^https?:\/\//.test(lead.website || '') ? lead.website : null
return (
<div className="ui-card pad" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div className="grow" style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{lead.firma}
{lead.kategorie && <Badge tone={lead.kategorie === 'A' ? 'ok' : 'muted'}>Kat. {lead.kategorie}</Badge>}
</div>
<div className="muted">
{[lead.ort, lead.branche].filter(Boolean).join(' · ')}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontWeight: 800, fontSize: 20, color: scoreColor(score) }}>{score}</div>
<div className="faint" style={{ fontSize: 11 }}>Score</div>
</div>
</div>
<div style={{ height: 6, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.min(100, score)}%`, height: '100%', background: scoreColor(score) }} />
</div>
<PipelineBar lead={lead} />
<div className="flex gap-2 wrap" style={{ alignItems: 'center' }}>
{lead.googleNote && (
<Badge tone="warn">
<FaStar /> {lead.googleNote} ({lead.anzahlBewertungen || 0})
</Badge>
)}
{lead.processed && <Badge tone="ok">Kunde angelegt</Badge>}
</div>
{lead.websiteStatus && (
<div className="muted" style={{ fontSize: 13 }}>
<strong>Website:</strong> {lead.websiteStatus.length > 120 && !open
? lead.websiteStatus.slice(0, 120) + '…'
: lead.websiteStatus}
</div>
)}
<div className="flex gap-2 wrap" style={{ fontSize: 13 }}>
{lead.email && (
<a href={`mailto:${lead.email}`} className="muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<FaEnvelope /> {lead.email}
</a>
)}
{lead.telefon && (
<a href={`tel:${lead.telefon.replace(/\s/g, '')}`} className="muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<FaPhone /> {lead.telefon}
</a>
)}
</div>
<div className="flex gap-2 wrap" style={{ alignItems: 'center' }}>
{lead.googleMapsLink && (
<Button size="sm" as="a" href={lead.googleMapsLink} target="_blank" rel="noreferrer" title="Google Maps" aria-label="Google Maps">
<FaMapLocationDot />
</Button>
)}
{websiteUrl && (
<Button size="sm" as="a" href={websiteUrl} target="_blank" rel="noreferrer" title="Bestehende Website" aria-label="Bestehende Website">
<FaGlobe />
</Button>
)}
{lead.repoUrl && (
<Button size="sm" as="a" href={lead.repoUrl} target="_blank" rel="noreferrer" title="Preview-Repo (Gitea)" aria-label="Preview-Repo">
<FaGitAlt />
</Button>
)}
{lead.previewUrl && (
<Button size="sm" variant="primary" as="a" href={lead.previewUrl} target="_blank" rel="noreferrer" title="Erstellte Vorschau-Website öffnen (Login-geschützt)" aria-label="Vorschau öffnen">
<FaEye />
</Button>
)}
{lead.woid && (
<Button
size="sm"
variant="ghost"
as={Link}
to={`/tickets?woid=${lead.woid}`}
title={`Akquise-Ticket WOID ${lead.woid} öffnen`}
aria-label={`Ticket WOID ${lead.woid}`}
>
<FaHashtag />{lead.woid}
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => setOpen((o) => !o)} title={open ? 'Weniger' : 'Details'} style={{ marginLeft: 'auto' }}>
{open ? <FaChevronUp /> : <FaChevronDown />}
</Button>
</div>
{open && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13 }}>
{lead.ansprechpartner && <div><strong>Ansprechpartner:</strong> {lead.ansprechpartner}</div>}
{lead.adresse && <div><strong>Adresse:</strong> {lead.adresse}</div>}
{lead.kernleistungen && <div><strong>Leistungen:</strong> {lead.kernleistungen}</div>}
{lead.hauptmaengel && <div><strong>Hauptmängel:</strong> {lead.hauptmaengel}</div>}
{lead.sympathie && <div><strong>Eindruck:</strong> {lead.sympathie}</div>}
{(lead.portalLogin || lead.portalPasswort) && (
<div className="flex gap-3 wrap">
{lead.portalLogin && <CopyableCredential label="Portal-Login" value={lead.portalLogin} />}
{lead.portalPasswort && <CopyableCredential label="Portal-Passwort" value={lead.portalPasswort} secret />}
</div>
)}
<PipelineLog pipeline={pipeline} />
{lead.pipelineStatus === 'failed' && lead.pipelineError && (
<div className="text-red" style={{ fontSize: 12 }}>
<FaTriangleExclamation /> {lead.pipelineError}
</div>
)}
<EmailDraft pipeline={pipeline} />
{lead.notiz && <div><strong>Notiz (Alt-Bestand):</strong> {lead.notiz}</div>}
<div className="faint" style={{ fontSize: 11 }}>
Aufgenommen: {lead.addedAt ? new Date(lead.addedAt).toLocaleDateString('de-DE') : '-'}
{lead.leadId ? ` · ${lead.leadId}` : ''}
</div>
</div>
)}
</div>
)
}
function LeadSettingsCard({ onClose }) {
const { settings, loading, saveSettings } = useLeadSettings()
const [form, setForm] = useState(null)
const [newStadt, setNewStadt] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null)
// Formular einmalig aus den geladenen Settings befuellen
if (!form && !loading && settings) {
setForm({
branche: settings.branche || '',
staedte: settings.staedte || [],
ganzDeutschland: Boolean(settings.ganzDeutschland),
maxLeadsProStadt: settings.maxLeadsProStadt ?? 5,
minBewertung: settings.minBewertung ?? 4.0,
emailPflicht: settings.emailPflicht !== false,
nurSchlechteWebsite: settings.nurSchlechteWebsite !== false,
zusatzHinweise: settings.zusatzHinweise || '',
})
}
const set = (key, value) => setForm((f) => ({ ...f, [key]: value }))
const addStadt = () => {
const s = newStadt.trim()
if (!s) return
if (!form.staedte.some((x) => x.toLowerCase() === s.toLowerCase())) {
set('staedte', [...form.staedte, s])
}
setNewStadt('')
}
const save = async () => {
setBusy(true)
setMsg(null)
const r = await saveSettings(form)
setBusy(false)
setMsg(r.success ? 'Gespeichert — gilt ab dem nächsten 06:00-Lauf.' : `Fehler: ${r.error}`)
}
return (
<Card
title="Sucheinstellungen der 06:00-Routine"
actions={<Button variant="ghost" size="sm" onClick={onClose}><FaXmark /></Button>}
>
{loading || !form ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : (
<>
<p className="muted" style={{ marginTop: 0, fontSize: 13 }}>
Diese Einstellungen liest die tägliche Recherche-Routine vor jedem Lauf.
Bereits vorhandene Leads und Kunden werden automatisch übersprungen.
</p>
<div className="flex gap-3 wrap">
<Field label="Branche" hint="z. B. Autolackiererei, Kfz-Werkstatt, Dachdecker">
<input
className="form-control"
value={form.branche}
onChange={(e) => set('branche', e.target.value)}
/>
</Field>
<Field label="Max. neue Leads pro Stadt">
<input
className="form-control"
type="number"
min={1}
max={20}
value={form.maxLeadsProStadt}
onChange={(e) => set('maxLeadsProStadt', e.target.value)}
style={{ width: 120 }}
/>
</Field>
<Field label="Min. Google-Bewertung">
<input
className="form-control"
type="number"
step={0.1}
min={0}
max={5}
value={form.minBewertung}
onChange={(e) => set('minBewertung', e.target.value)}
style={{ width: 120 }}
/>
</Field>
</div>
<Field
label="Städte / Regionen"
hint={form.ganzDeutschland ? 'Ganz Deutschland aktiv — die Routine wählt selbst passende Regionen.' : 'Die Routine recherchiert in diesen Regionen.'}
>
<div className="flex gap-2 wrap" style={{ alignItems: 'center' }}>
{form.staedte.map((s) => (
<Badge key={s} tone={form.ganzDeutschland ? 'muted' : 'info'}>
{s}
<button
type="button"
onClick={() => set('staedte', form.staedte.filter((x) => x !== s))}
style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: '0 0 0 4px', display: 'inline-flex' }}
title={`${s} entfernen`}
>
<FaXmark />
</button>
</Badge>
))}
<input
className="form-control"
placeholder="Stadt hinzufügen..."
value={newStadt}
onChange={(e) => setNewStadt(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addStadt() } }}
disabled={form.ganzDeutschland}
style={{ width: 200 }}
/>
<Button size="sm" type="button" onClick={addStadt} disabled={form.ganzDeutschland}>
<FaPlus />
</Button>
</div>
</Field>
<div className="flex gap-3 wrap" style={{ margin: '10px 0' }}>
<label className="flex gap-2" style={{ alignItems: 'center', cursor: 'pointer' }}>
<input
type="checkbox"
checked={form.ganzDeutschland}
onChange={(e) => set('ganzDeutschland', e.target.checked)}
/>
Komplett Deutschland
</label>
<label className="flex gap-2" style={{ alignItems: 'center', cursor: 'pointer' }}>
<input
type="checkbox"
checked={form.emailPflicht}
onChange={(e) => set('emailPflicht', e.target.checked)}
/>
Nur Leads mit auffindbarer E-Mail
</label>
<label className="flex gap-2" style={{ alignItems: 'center', cursor: 'pointer' }}>
<input
type="checkbox"
checked={form.nurSchlechteWebsite}
onChange={(e) => set('nurSchlechteWebsite', e.target.checked)}
/>
Nur ohne / mit veralteter Website
</label>
</div>
<Field label="Zusätzliche Hinweise für die Recherche" hint="Freitext, wird der Routine wörtlich mitgegeben.">
<textarea
className="form-control"
rows={3}
value={form.zusatzHinweise}
onChange={(e) => set('zusatzHinweise', e.target.value)}
placeholder="z. B. Familienbetriebe bevorzugen, keine Franchise-Ketten..."
/>
</Field>
{msg && <div className={msg.startsWith('Fehler') ? 'text-red' : 'muted'} style={{ marginBottom: 8, fontSize: 13 }}>{msg}</div>}
<div className="flex gap-2">
<Button variant="primary" onClick={save} disabled={busy}>
{busy ? <FaSpinner className="spinner" /> : <><FaFloppyDisk /> Speichern</>}
</Button>
</div>
</>
)}
</Card>
)
}
+82 -150
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import LetterGlitch from '../components/LetterGlitch'
import Logo from '../components/Logo'
import { safeReturnPath } from '../lib/returnPath'
import { NO_STAFF_ACCESS } from '../lib/staffAccess'
@@ -83,167 +83,99 @@ export default function LoginPage() {
setPassword('')
}
const linkButton = {
background: 'none',
border: 0,
padding: 0,
color: '#e2e8f0',
textDecoration: 'underline',
cursor: 'pointer',
font: 'inherit',
}
const codeStep = mfaPending && (
<form onSubmit={handleCode}>
{error && (
<div className="bg-red text-white p-1 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<p style={{ marginTop: 0 }}>
<>
<h1>Code eingeben</h1>
<p className="sub">
{!mfaPending.challengeId
? 'Wir schicken dir einen Code an deine E-Mail-Adresse.'
: `Wir haben dir einen Code an ${mfaPending.email || 'deine E-Mail-Adresse'} geschickt.`}
</p>
{notice && <p style={{ marginTop: 0 }}>{notice}</p>}
{mfaPending.challengeId ? (
<>
<div className="form-group">
<label className="form-label" htmlFor="mfa-code">Code aus der E-Mail</label>
<input
id="mfa-code"
type="text"
className="form-control"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
required
autoFocus
style={{ letterSpacing: '0.3em' }}
/>
</div>
<button type="submit" className="btn btn-green" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Prüfe…' : 'Anmelden'}
</button>
<p style={{ textAlign: 'center', marginBottom: 0 }}>
<button type="button" style={linkButton} onClick={handleSend} disabled={loading}>Code erneut senden</button>
{' · '}
<button type="button" style={linkButton} onClick={handleCancel}>Abbrechen</button>
</p>
</>
) : (
<>
<button type="button" className="btn btn-green" style={{ width: '100%' }} onClick={handleSend} disabled={loading}>
<form onSubmit={handleCode}>
{error && <div className="msg err" role="alert">{error}</div>}
{notice && <div className="msg ok">{notice}</div>}
{mfaPending.challengeId ? (
<>
<div className="field">
<label htmlFor="mfa-code">Code aus der E-Mail</label>
<input
id="mfa-code"
type="text"
className="input code-input"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
required
autoFocus
/>
</div>
<button type="submit" className="btn btn-primary btn-block" disabled={loading}>
{loading ? 'Prüfe…' : 'Anmelden'}
</button>
</>
) : (
<button type="button" className="btn btn-primary btn-block" onClick={handleSend} disabled={loading}>
{loading ? 'Sende…' : 'Code senden'}
</button>
<p style={{ textAlign: 'center', marginBottom: 0 }}>
<button type="button" style={linkButton} onClick={handleCancel}>Abbrechen</button>
</p>
</>
)}
</form>
)}
</form>
<div className="login-links">
{mfaPending.challengeId && (
<button type="button" className="link" onClick={handleSend} disabled={loading}>Code erneut senden</button>
)}
<button type="button" className="link" onClick={handleCancel}>Abbrechen</button>
</div>
</>
)
return (
<div style={{
position: 'relative',
minHeight: '100vh',
width: '100%',
overflow: 'hidden'
}}>
{/* LetterGlitch Hintergrund */}
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 0
}}>
<LetterGlitch
glitchSpeed={50}
centerVignette={true}
outerVignette={false}
smooth={true}
/>
</div>
{/* Login-Formular */}
<div style={{
position: 'relative',
zIndex: 1,
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: '400px',
boxShadow: '0 10px 40px rgba(0,0,0,0.5)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
background: 'transparent',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '8px',
marginBottom: '16px'
}}>
<div style={{
padding: '12px 16px',
background: 'transparent',
color: '#fff',
fontWeight: 'bold',
borderBottom: '1px solid rgba(255, 255, 255, 0.2)',
textAlign: 'center'
}}>
<h2>Webklar WOMS 2.0</h2>
</div>
<div style={{ padding: '16px', color: '#e2e8f0' }}>
{codeStep || <form onSubmit={handleSubmit}>
{error && (
<div className="bg-red text-white p-1 mb-2" style={{ borderRadius: '4px' }}>
{error}
<>
<div className="login">
<div className="login-box">
<Logo size={40} />
{codeStep || (
<>
<h1>Anmelden</h1>
<p className="sub">Ticketsystem für das WEBklar-Team</p>
<form onSubmit={handleSubmit}>
{error && <div className="msg err" role="alert">{error}</div>}
<div className="field">
<label htmlFor="login-email">E-Mail</label>
<input
id="login-email"
type="email"
className="input"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
required
placeholder="deine@email.com"
/>
</div>
)}
<div className="form-group">
<label className="form-label">Email</label>
<input
type="email"
className="form-control"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="deine@email.com"
/>
</div>
<div className="form-group">
<label className="form-label">Password</label>
<input
type="password"
className="form-control"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="••••••••"
minLength={8}
/>
</div>
<button
type="submit"
className="btn btn-green"
style={{ width: '100%' }}
disabled={loading}
>
{loading ? 'Login läuft...' : 'Login'}
</button>
</form>}
</div>
<div className="field">
<label htmlFor="login-password">Passwort</label>
<input
id="login-password"
type="password"
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
minLength={8}
/>
</div>
<button type="submit" className="btn btn-primary btn-block" disabled={loading}>
{loading ? 'Login läuft...' : 'Weiter'}
</button>
</form>
</>
)}
</div>
</div>
</div>
<div className="login-foot">Webklar Ticket-Plattform · © {new Date().getFullYear()}</div>
</>
)
}
+42 -80
View File
@@ -1,24 +1,19 @@
import { useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { format, addDays, startOfWeek, isSameDay } from 'date-fns'
import { de } from 'date-fns/locale'
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'
import { PageHeader, Card, Button } from '../components/ui'
import { Link } from 'react-router-dom'
import { addDays, startOfWeek, isSameDay } from 'date-fns'
import { PageHeader, Button } from '../components/ui'
import { useWorkorders } from '../hooks/useWorkorders'
import { useEmployees } from '../hooks/useEmployees'
import { groupAppointmentsByDay, countInRange, dayKey } from '../lib/planboard'
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
import {
planboardSubtitle, dayLabels, appointmentClass, appointmentTimeLabel, appointmentOwnerLabel, appointmentTitle,
} from '../lib/planboardView'
/* Termine haengen an den Tickets (startDate/startTime/endTime/assignedTo);
die Aufbereitung steckt in src/lib/planboard.js. */
const TYPE_COLORS = {
akquise: { bg: 'rgba(198,113,57,0.16)', fg: '#c67139' },
webpage: { bg: 'rgba(122,138,94,0.18)', fg: '#7a8a5e' },
project: { bg: 'rgba(122,138,94,0.18)', fg: '#7a8a5e' },
}
die Aufbereitung steckt in src/lib/planboard.js, die Texte in src/lib/planboardView.js. */
export default function PlanboardPage() {
const navigate = useNavigate()
const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }))
const { workorders, loading } = useWorkorders({ limit: 500 })
const { employees } = useEmployees()
@@ -34,83 +29,50 @@ export default function PlanboardPage() {
<div className="page">
<PageHeader
title="Planboard"
subtitle={loading ? 'Wochenplanung – lädt…' : `Wochenplanung – ${weekCount} ${weekCount === 1 ? 'Termin' : 'Termine'} diese Woche`}
subtitle={planboardSubtitle({ weekStart: currentWeek, count: weekCount, loading })}
actions={
<div className="flex gap-2 items-center">
<Button variant="ghost" onClick={() => navigateWeek(-1)}><FaChevronLeft /></Button>
<span className="muted" style={{ minWidth: 200, textAlign: 'center' }}>
{format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')}
</span>
<Button variant="ghost" onClick={() => navigateWeek(1)}><FaChevronRight /></Button>
<Button variant="ghost" onClick={goToday}>Heute</Button>
</div>
<>
<Button variant="quiet" aria-label="Vorherige Woche" onClick={() => navigateWeek(-1)}>←</Button>
<Button onClick={goToday}>Heute</Button>
<Button variant="quiet" aria-label="Nächste Woche" onClick={() => navigateWeek(1)}>→</Button>
</>
}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 12 }}>
{weekDays.map((day) => {
<div className="week">
{weekDays.map((day, index) => {
const entries = byDay[dayKey(day)] || []
const today = isSameDay(day, new Date())
const labels = dayLabels(day)
return (
<Card key={day.toISOString()} pad={false}>
<div style={{
padding: '10px 12px',
borderBottom: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
background: today ? 'rgba(198,113,57,0.10)' : undefined,
}}>
<strong style={{ fontSize: 13 }}>{format(day, 'EEEE', { locale: de })}</strong>
<span className="faint" style={{ fontSize: 12 }}>{format(day, 'dd.MM')}</span>
<div
key={day.toISOString()}
className={`day${today ? ' today' : ''}${index >= 5 ? ' weekend' : ''}`}
role="group"
aria-label={labels.full}
aria-current={today ? 'date' : undefined}
>
<div className="day-head">
<span className="wd">{labels.weekday}</span>
<span className="dn">{labels.number}</span>
</div>
<div style={{ padding: 8, minHeight: 120, display: 'flex', flexDirection: 'column', gap: 6 }}>
{entries.length === 0 && (
<p className="faint" style={{ fontSize: 12, padding: '4px 4px 0' }}>Keine Termine</p>
)}
{entries.length === 0 && <div className="none">Keine Termine</div>}
{entries.map((entry) => {
const color = TYPE_COLORS[String(entry.type).toLowerCase()]
|| { bg: 'rgba(127,127,127,0.10)', fg: 'var(--border)' }
return (
<button
key={entry.id}
type="button"
title={`Ticket #${entry.woid} öffnen`}
onClick={() => navigate(`/tickets?woid=${encodeURIComponent(entry.woid)}`)}
style={{
textAlign: 'left',
border: '1px solid var(--border)',
borderLeft: `3px solid ${color.fg}`,
borderRadius: 8,
background: color.bg,
color: 'inherit',
padding: '8px 10px',
cursor: 'pointer',
font: 'inherit',
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<span style={{ fontSize: 12, fontWeight: 700 }}>
{entry.time ? `${entry.time}${entry.endTime ? `–${entry.endTime}` : ''}` : 'ohne Uhrzeit'}
</span>
<span style={{ fontSize: 12.5, fontWeight: 600, lineHeight: 1.25 }}>
{entry.customerName || entry.topic}
</span>
{entry.customerName && (
<span className="faint" style={{ fontSize: 11.5, lineHeight: 1.25 }}>{entry.topic}</span>
)}
<span className="faint" style={{ fontSize: 11 }}>
#{entry.woid}
{entry.employeeName ? ` · ${entry.employeeShort || entry.employeeName}` : ' · niemand zugewiesen'}
</span>
</button>
)
})}
</div>
</Card>
{entries.map((entry) => (
<Link
key={entry.id}
className={appointmentClass(entry.type)}
to={ticketPath(ticketRef({ woid: entry.woid, $id: entry.id }))}
title={appointmentTitle(entry)}
>
<div className="tm">{appointmentTimeLabel(entry)}</div>
<div className="tt">{entry.customerName || entry.topic}</div>
{entry.customerName && <div className="cu">{entry.topic}</div>}
<div className="cu">{appointmentOwnerLabel(entry)}</div>
</Link>
))}
</div>
)
})}
</div>
+2 -3
View File
@@ -369,7 +369,7 @@ export default function ProjectsPage() {
const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
return (
<div className="main-content">
<div className="page">
<header className="text-center mb-2">
<h2>Website-Projekte</h2>
</header>
@@ -743,8 +743,7 @@ export default function ProjectsPage() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', marginTop: '16px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn"
style={{ background: editProject.archived ? 'var(--accent)' : 'var(--danger)', color: '#fff' }}
className={editProject.archived ? 'btn' : 'link-danger'}
onClick={() => handleArchiveToggle(editProject)}
disabled={archiveBusy || editLoading}
>
+6 -5
View File
@@ -7,6 +7,7 @@ import {
ageLabel, formatBytes, formatNumber, formatPct, formatSince, usedPct,
} from '../lib/serverpulsFormat'
import Chart from '../components/serverpuls/Chart'
import { CHART } from '../components/serverpuls/chartColors'
import Sparkline from '../components/serverpuls/Sparkline'
import AppsTable from '../components/serverpuls/AppsTable'
import '../styles/serverpuls.css'
@@ -220,10 +221,10 @@ export default function ServerpulsPage() {
const avg = pts.map((p) => p.v)
const withMax = chart.res !== 'raw' && ['cpu', 'load', 'zombies'].includes(chart.metric)
const data = [xs, avg]
const series = [{ label: m.label, color: '#10b981', fill: 'rgba(16,185,129,0.10)' }]
const series = [{ label: m.label, color: CHART.line, fill: CHART.fill }]
if (withMax) {
data.push(pts.map((p) => p.max ?? null))
series.push({ label: `${m.label} (Spitze)`, color: '#f59e0b', dash: [4, 4] })
series.push({ label: `${m.label} (Spitze)`, color: CHART.peak, dash: [4, 4] })
}
return { data, series }
}, [chart, m.label])
@@ -232,9 +233,9 @@ export default function ServerpulsPage() {
if (!detail) return null
const days = (detail.size || []).map((s) => Math.round(new Date(`${s.day}T12:00:00Z`).getTime() / 1000))
return {
size: { data: [days, (detail.size || []).map((s) => s.bytes)], series: [{ label: 'Größe', color: '#3b82f6', fill: 'rgba(59,130,246,0.10)' }] },
mem: { data: [(detail.mem || []).map((p) => Math.round(p.ts / 1000)), (detail.mem || []).map((p) => p.v)], series: [{ label: 'RAM', color: '#10b981' }] },
cpu: { data: [(detail.cpu || []).map((p) => Math.round(p.ts / 1000)), (detail.cpu || []).map((p) => p.v)], series: [{ label: 'CPU', color: '#f59e0b' }] },
size: { data: [days, (detail.size || []).map((s) => s.bytes)], series: [{ label: 'Größe', color: CHART.line, fill: CHART.fill }] },
mem: { data: [(detail.mem || []).map((p) => Math.round(p.ts / 1000)), (detail.mem || []).map((p) => p.v)], series: [{ label: 'RAM', color: CHART.line }] },
cpu: { data: [(detail.cpu || []).map((p) => Math.round(p.ts / 1000)), (detail.cpu || []).map((p) => p.v)], series: [{ label: 'CPU', color: CHART.line }] },
}
}, [detail])
+2 -1
View File
@@ -4,6 +4,7 @@ import { FaSpinner } from 'react-icons/fa6'
import { dotColor } from '../lib/previewEnv'
import { fetchServices } from '../lib/projectKindsApi'
import { servicesCards } from '../lib/servicesView'
import { ticketPath } from '../lib/ticketRoutes'
import { PageHeader, Card, Button, Badge } from '../components/ui'
export default function ServicesPage() {
@@ -69,7 +70,7 @@ export default function ServicesPage() {
{[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'}
</div>
</div>
{p.woid && <Link to={`/tickets?woid=${encodeURIComponent(p.woid)}`}>Ticket #{p.woid}</Link>}
{p.woid && <Link to={ticketPath(p.woid)}>Ticket #{p.woid}</Link>}
{c.key === 'missing' && (
<Button variant="primary" onClick={() => navigate(`/projects/zuordnen/${p.id}`)}>Zuordnen</Button>
)}
+310
View File
@@ -0,0 +1,310 @@
import { useMemo, useState } from 'react'
import { Link, Navigate, useLocation, useNavigate, useParams } from 'react-router-dom'
import { PageHeader, Tabs, Card, EmptyState, Button } from '../components/ui'
import UrgentToggle from '../components/UrgentToggle'
import WorksheetSummary from '../components/WorksheetSummary'
import WorksheetList from '../components/WorksheetList'
import ProjectContextPanel from '../components/ProjectContextPanel'
import TicketProjects from '../components/TicketProjects'
import CreateWorksheetModal from '../components/CreateWorksheetModal'
import WorksheetTimesModal from '../components/WorksheetTimesModal'
import OwnSupportModal from '../components/OwnSupportModal'
import { useAuth } from '../context/AuthContext'
import { useEmployees } from '../hooks/useEmployees'
import { useWorkorder } from '../hooks/useWorkorder'
import { useWorksheets } from '../hooks/useWorksheets'
import { useCustomerAbo } from '../hooks/useCustomerAbo'
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
import { elapsedLabel } from '../lib/elapsed'
import { canAddOwnSupport } from '../lib/support'
import { canEditWorksheetTimes, serviceTypeLabel } from '../lib/ticketForm'
import { listBackPath, resolveTicketTab, ticketPath, ticketRef } from '../lib/ticketRoutes'
import { statusSelectOptions, ticketTypeLabel } from '../lib/ticketStatus'
import { appointmentText, assigneeOptions, dueText, ticketNumber } from '../lib/ticketView'
import { createWorksheetFlow } from '../lib/worksheetFlow'
// Ein Ticket als eigene Seite: /tickets/<woid oder $id> (Details) und /tickets/<ref>/arbeitsblaetter.
// Alle Hooks stehen vor jeder Weiterleitung, sonst bricht der Wechsel /tickets/5/xyz -> /tickets/5 die Hook-Reihenfolge.
export default function TicketPage() {
const { ref, tab } = useParams()
const location = useLocation()
const { ticket, loading, error, notFound, updateWorkorder } = useWorkorder(ref)
const activeTab = resolveTicketTab(tab)
if (activeTab === null) return <Navigate replace to={ticketPath(ref)} state={location.state} />
const back = <Button as={Link} to={listBackPath(location.state)}>Zurück</Button>
if (loading) {
return (
<div className="page">
<div className="loading">Wird geladen …</div>
</div>
)
}
if (notFound) {
return (
<div className="page">
<EmptyState title="Ticket nicht gefunden" action={back} />
</div>
)
}
if (error || !ticket) {
return (
<div className="page">
<p className="msg err" role="alert">Fehler: {error}</p>
<div className="mt-2">{back}</div>
</div>
)
}
return <TicketView key={ticket.$id} ticket={ticket} updateWorkorder={updateWorkorder} tab={activeTab} />
}
// Eine gelesene Uhrzeit oder Spanne ("09:30" bzw. "09:30–10:30") bricht nicht in sich um, nur davor am Komma.
// Unlesbare Altwerte (Rohtext) brechen weiter frei um.
const TIME_RANGE = /^\d{2}:\d{2}(–\d{2}:\d{2})?$/
function AppointmentText({ text }) {
const at = text.lastIndexOf(', ')
if (at === -1 || !TIME_RANGE.test(text.slice(at + 2))) return text
return (
<>
{text.slice(0, at)}, <span className="nowrap">{text.slice(at + 2)}</span>
</>
)
}
function TicketView({ ticket, updateWorkorder, tab }) {
const location = useLocation()
const navigate = useNavigate()
const { user, employee } = useAuth()
// Einmal laden und an die Dialoge reichen (der Zuständig-Filter und die Unterstützer-Liste teilen sich die Antwort)
const { employees, loading: employeesLoading, error: employeesError } = useEmployees()
const employeesState = useMemo(
() => ({ employees, loading: employeesLoading, error: employeesError }),
[employees, employeesLoading, employeesError]
)
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
const [editTimesWorksheet, setEditTimesWorksheet] = useState(null)
const [ownSupportWorksheet, setOwnSupportWorksheet] = useState(null)
// Werte, die gerade gespeichert werden: das Feld zeigt den neuen Wert und ist gesperrt, bei einem Fehler springt es zurück
const [pending, setPending] = useState({})
const woid = ticket.woid || null
const {
worksheets,
loading: worksheetsLoading,
error: worksheetsError,
createWorksheet,
updateWorksheet,
updateWorksheetTimes,
saveOwnSupport,
} = useWorksheets(woid)
// Abo-Stundenkonto gibt es nur bei Wartungs-Abo; erst laden, wenn Arbeitsblätter gebraucht werden
const { hoursAllowed: aboHoursAllowed } = useCustomerAbo(
ticket.customerId,
tab === 'arbeitsblaetter' || showCreateWorksheet
)
const isPending = (field) => pending[field] !== undefined
const shown = (field, current) => (isPending(field) ? pending[field] : current)
// Fehler beim Schreiben bleiben still (wie bisher): bei Misserfolg ändert sich das Ticket nicht und das Feld springt zurück
const saveField = async (field, value) => {
if (isPending(field)) return
setPending((prev) => ({ ...prev, [field]: value }))
try {
await updateWorkorder(ticket.$id, { [field]: value })
} finally {
setPending((prev) => {
const next = { ...prev }
delete next[field]
return next
})
}
}
const goToTab = (id) => navigate(ticketPath(ticketRef(ticket), id), { state: location.state })
// Abo-Stunden: Arbeitszeit vom Stundenguthaben des Wartungs-Abos abziehen oder nicht
const togglePlan = (ws) => updateWorksheet(ws.$id, { countsToPlan: !ws.countsToPlan })
const handleCreateWorksheet = async (worksheetData, currentUser) => {
const result = await createWorksheetFlow({
ticket,
worksheetData,
user: currentUser,
createWorksheet,
updateTicket: updateWorkorder,
updateCustomer: (id, data) => databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, id, data),
})
// Das neue Blatt soll sichtbar sein
if (result?.success && tab !== 'arbeitsblaetter') goToTab('arbeitsblaetter')
return result
}
const number = ticketNumber(ticket)
const customerLabel = ticket.customerName || 'Unbekannt'
const worksheetCount = !woid || worksheetsLoading ? undefined : worksheetsError ? '–' : worksheets.length
const appointment = appointmentText(ticket)
const due = dueText(ticket)
return (
<div className="page">
<PageHeader
crumb={[{ label: 'Tickets', to: listBackPath(location.state) }, { label: number }]}
eyebrow={ticket.type ? `${number} · ${ticketTypeLabel(ticket.type)}` : number}
title={ticket.topic || ticket.title || 'Kein Betreff'}
subtitle={
<>
{ticket.customerId ? (
<Link className="link" to={'/customers/' + ticket.customerId}>{customerLabel}</Link>
) : (
customerLabel
)}
{ticket.customerLocation ? ` · ${ticket.customerLocation}` : ''}
</>
}
actions={
<>
<UrgentToggle
value={shown('priority', ticket.priority)}
onChange={(priority) => saveField('priority', priority)}
disabled={isPending('priority')}
/>
<select
className="select"
aria-label="Status"
value={shown('status', ticket.status || 'Open')}
disabled={isPending('status')}
onChange={(e) => saveField('status', e.target.value)}
>
{statusSelectOptions(ticket.status).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<Button
variant="primary"
type="button"
disabled={!woid}
title={woid ? undefined : 'Für dieses Ticket gibt es keine Ticketnummer'}
onClick={() => setShowCreateWorksheet(true)}
>
Arbeitsblatt hinzufügen
</Button>
</>
}
/>
<div className="facts">
<div className="fact">
<div className="k"><label htmlFor="ticket-assignee">Zuständig</label></div>
<div className="v">
<select
className="select select-quiet"
id="ticket-assignee"
value={shown('assignedTo', ticket.assignedTo || '')}
disabled={employeesLoading || isPending('assignedTo')}
onChange={(e) => saveField('assignedTo', e.target.value)}
>
{assigneeOptions(ticket, employees).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div>
<div className="fact">
<div className="k">Termin</div>
<div className="v">{appointment ? <AppointmentText text={appointment} /> : <span className="faint">Kein Termin</span>}</div>
</div>
<div className="fact">
<div className="k">Fällig</div>
<div className="v">{due || '–'}</div>
</div>
<div className="fact">
<div className="k">Einsatz</div>
<div className="v">{serviceTypeLabel(ticket.serviceType) || '–'}</div>
</div>
<div className="fact">
<div className="k">Angefragt von</div>
<div className="v">{ticket.requestedBy || '–'}</div>
</div>
<div className="fact">
<div className="k">Erstellt</div>
<div className="v">{elapsedLabel(ticket)}</div>
</div>
</div>
<Tabs
tabs={[
{ id: 'details', label: 'Details' },
{ id: 'arbeitsblaetter', label: 'Arbeitsblätter', count: worksheetCount },
]}
active={tab}
onChange={(id) => { if (id !== tab) goToTab(id) }}
/>
{tab === 'details' && (
<>
<Card title="Ticket-Beschreibung">
<p className="prose">{ticket.details || 'Keine Details vorhanden.'}</p>
</Card>
<TicketProjects ticket={ticket} />
<ProjectContextPanel ticket={ticket} />
</>
)}
{tab === 'arbeitsblaetter' && (
!woid ? (
<p className="muted">Für dieses Ticket gibt es keine Ticketnummer, Arbeitsblätter sind nicht möglich.</p>
) : worksheetsError ? (
<p className="msg err" role="alert">{worksheetsError}</p>
) : (
<>
<WorksheetSummary worksheets={worksheets} />
<WorksheetList
worksheets={worksheets}
loading={worksheetsLoading}
onTogglePlan={togglePlan}
aboHoursAllowed={aboHoursAllowed}
canEditTimes={(ws) => canEditWorksheetTimes(ws, user, employee)}
onEditTimes={setEditTimesWorksheet}
canAddSupport={(ws) => canAddOwnSupport(ws, user, employee)}
onOwnSupport={setOwnSupportWorksheet}
currentUserId={user?.$id}
/>
</>
)
)}
<CreateWorksheetModal
isOpen={showCreateWorksheet}
onClose={() => setShowCreateWorksheet(false)}
workorder={ticket}
onCreate={handleCreateWorksheet}
aboHoursAllowed={aboHoursAllowed}
employeesState={employeesState}
/>
{ownSupportWorksheet && (
<OwnSupportModal
worksheet={ownSupportWorksheet}
currentUserId={user?.$id}
onClose={() => setOwnSupportWorksheet(null)}
onSave={(ws, task) => saveOwnSupport(ws, task, user, employee)}
/>
)}
{editTimesWorksheet && (
<WorksheetTimesModal
worksheet={editTimesWorksheet}
currentUserId={user?.$id}
onClose={() => setEditTimesWorksheet(null)}
onSave={(ws, times) => updateWorksheetTimes(ws, times, user, employee)}
employeesState={employeesState}
/>
)}
</div>
)
}
+235 -131
View File
@@ -1,78 +1,208 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { FaAngleDown, FaSpinner, FaPlus, FaSliders } from 'react-icons/fa6'
import { useWorkorders } from '../hooks/useWorkorders'
import { useCustomers } from '../hooks/useCustomers'
import TicketCard from '../components/TicketCard'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { PageHeader, Tabs, Button, EmptyState, StatusPill, UrgentPill } from '../components/ui'
import CreateTicketModal from '../components/CreateTicketModal'
import { PageHeader, Card, Button, EmptyState } from '../components/ui'
import { STATUS_GROUPS, DEFAULT_STATUS_GROUPS, statusValuesFor, TYPE_FILTERS } from '../lib/ticketStatus'
import { useCustomers } from '../hooks/useCustomers'
import { useEmployees } from '../hooks/useEmployees'
import { useTicketCounts } from '../hooks/useTicketCounts'
import { useTicketMinutes } from '../hooks/useTicketMinutes'
import { useWorkorders } from '../hooks/useWorkorders'
import { elapsedLabel } from '../lib/elapsed'
import {
LIST_TABS,
listFilters,
nextLimit,
parseListParams,
serializeListParams,
withFilter,
woidRedirectTarget,
} from '../lib/ticketListUrl'
import { isRowActivation } from '../lib/rowClick'
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
import { TYPE_FILTERS, ticketTypeLabel } from '../lib/ticketStatus'
import { TIME_MAX_ROWS, minutesCellText } from '../lib/ticketTimes'
import { appointmentText, assigneeLabel, ticketNumber } from '../lib/ticketView'
const ALL_GROUPS = STATUS_GROUPS.map((group) => group.id)
const linkButton = { fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 }
// Pause nach dem letzten Tastendruck, bis der Suchtext in die Adresse übernommen wird
const SEARCH_DELAY_MS = 300
// Aus Suchtext, Art, Status-Gruppen und "Nur dringende" die Abfrage fuer useWorkorders bauen.
// Sind alle oder keine Status-Gruppen gewaehlt, wird nicht nach Status gefiltert.
function toFilters({ search, typeId, groups, urgentOnly }) {
const typeFilter = TYPE_FILTERS.find((filter) => filter.id === typeId) || TYPE_FILTERS[0]
const allGroups = groups.length === 0 || groups.length === ALL_GROUPS.length
return {
status: allGroups ? [] : statusValuesFor(groups),
type: typeFilter.types || [],
excludeType: typeFilter.excludeType,
priority: urgentOnly ? [3, 4] : [],
search: search.trim() || undefined,
woid: undefined,
}
// Altlinks aus den Leads (Liste mit Suchteil woid=123) führen zur Ticketseite; dann läuft keine Abfrage der Liste
export default function TicketsPage() {
const [searchParams] = useSearchParams()
const target = woidRedirectTarget(searchParams)
if (target) return <Navigate replace to={target} />
return <TicketList />
}
const DEFAULT_SELECTION = { search: '', typeId: 'orders', groups: DEFAULT_STATUS_GROUPS, urgentOnly: false }
function countText(n, searching) {
if (searching) return n === 1 ? '1 Treffer' : `${n} Treffer`
return n === 1 ? '1 Ticket' : `${n} Tickets`
}
export default function TicketsPage() {
const [limit, setLimit] = useState(10)
const [filters, setFilters] = useState({ ...toFilters(DEFAULT_SELECTION), limit: 10 })
const [searchText, setSearchText] = useState('')
const [typeId, setTypeId] = useState(DEFAULT_SELECTION.typeId)
const [groups, setGroups] = useState(DEFAULT_SELECTION.groups)
const [urgentOnly, setUrgentOnly] = useState(false)
function TicketList() {
const [searchParams, setSearchParams] = useSearchParams()
const location = useLocation()
const navigate = useNavigate()
const state = useMemo(() => parseListParams(searchParams), [searchParams])
const filters = useMemo(() => listFilters(state), [state])
// Wohin "Zurück" auf der Ticketseite führt: dieselbe Liste mit denselben Filtern
const listSearch = location.search
const toggleGroup = (id) => {
setGroups((prev) => (prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id]))
// Jede Änderung geht in die Adresse und ersetzt den Eintrag (der Zurück-Knopf springt nicht durch Filterschritte)
const write = (change) => {
setSearchParams((prev) => serializeListParams(change(parseListParams(prev))), { replace: true })
}
const setFilter = (patch) => write((current) => withFilter(current, patch))
const loadMore = () => write((current) => ({ ...current, limit: nextLimit(current) }))
// Der Timer der Suche ruft die Schreib-Funktion des jüngsten Renders (setSearchParams liest die Adresse aus seinem Render)
const writeRef = useRef(write)
writeRef.current = write
const { workorders, loading, error, updateWorkorder, createWorkorder } = useWorkorders(filters)
const { customers } = useCustomers()
// Suche: lokaler Text, Übernahme in die Adresse nach Tipp-Pause oder mit Enter.
// appliedRef merkt den zuletzt übernommenen (bzw. zuletzt aus der Adresse gelesenen) Suchtext:
// eigene Übernahmen laufen nicht in den Text zurück (sonst wären Leerzeichen am Ende weg),
// nur eine Änderung von außen (Zurück-Knopf, Link in der Seitenleiste) setzt den Text neu.
const [searchText, setSearchText] = useState(state.search)
const appliedRef = useRef(state.search)
const [showCreateModal, setShowCreateModal] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const [searchParams] = useSearchParams()
const commitSearch = useCallback((text) => {
const next = text.trim()
if (next === appliedRef.current) return
appliedRef.current = next
writeRef.current((current) => withFilter(current, { search: next }))
}, [])
// Deep-Link aus den Leads: /tickets?woid=123 zeigt genau dieses Ticket,
// egal welche Art (auch Akquise) und welcher Status
useEffect(() => {
const w = searchParams.get('woid')
if (!w) return
setSearchText(w)
setTypeId('all')
setGroups(ALL_GROUPS)
setFilters((prev) => ({ ...prev, status: [], type: [], excludeType: undefined, priority: [], search: undefined, woid: w }))
}, [searchParams])
if (state.search === appliedRef.current) return
appliedRef.current = state.search
setSearchText(state.search)
}, [state.search])
const applyFilters = (e) => {
e?.preventDefault()
setFilters((prev) => ({ ...prev, ...toFilters({ search: searchText, typeId, groups, urgentOnly }), limit }))
}
useEffect(() => {
if (searchText.trim() === appliedRef.current) return
const timer = setTimeout(() => commitSearch(searchText), SEARCH_DELAY_MS)
return () => clearTimeout(timer)
}, [searchText, commitSearch])
const handleUpdate = async (id, data) => { await updateWorkorder(id, data) }
const { workorders, loading, error, createWorkorder } = useWorkorders(filters)
const { counts } = useTicketCounts({ typeId: state.typeId, urgentOnly: state.urgentOnly })
const { customers } = useCustomers()
// Einmal laden: die Liste zeigt Namen, der Dialog "Neues Ticket" braucht dieselben Mitarbeiter
const { employees } = useEmployees()
const { byWoid, capped, version } = useTicketMinutes(workorders)
// Mit welchem Limit wurden die gezeigten Zeilen geladen? Nach "Mehr laden" ist state.limit schon größer,
// die Zeilen sind es erst mit der Antwort: der Knopf bleibt bis dahin stehen (und ist gesperrt).
// Im Ruhezustand heißt das: Zeilen >= state.limit und state.limit unter der Obergrenze von nextLimit.
const [rowsFor, setRowsFor] = useState({ rows: workorders, limit: state.limit })
if (rowsFor.rows !== workorders) setRowsFor({ rows: workorders, limit: state.limit })
const hasMore =
!state.search && nextLimit({ limit: rowsFor.limit }) > rowsFor.limit && workorders.length >= rowsFor.limit
const [showCreate, setShowCreate] = useState(false)
const openCreate = () => setShowCreate(true)
const handleCreate = async (data) => {
const result = await createWorkorder(data)
if (result.success) setShowCreateModal(false)
if (result.success) {
setShowCreate(false)
navigate(ticketPath(ticketRef(result.data)), { state: { listSearch } })
}
return result
}
const loadMore = () => {
const next = limit + 10
setLimit(next)
setFilters((prev) => ({ ...prev, limit: next }))
// Zähler: bei Suche aus, bei Fehler "–", vor der ersten Antwort fehlt die Zahl
const tabs = LIST_TABS.map((tab) => {
const count = state.search || !(tab.id in counts) ? undefined : counts[tab.id] === null ? '–' : counts[tab.id]
return { id: tab.id, label: tab.label, count }
})
// Die Art steht in der Zeile, solange der Filter keine einzelne Art festlegt
const showType = state.typeId === 'orders' || state.typeId === 'all'
// byWoid ist eine an Ort und Stelle ergänzte Map: erst `version` zeigt eine Änderung an
const rows = useMemo(
() =>
workorders.map((ticket, index) => {
const path = ticketPath(ticketRef(ticket))
const assignee = assigneeLabel(ticket, employees)
return (
<tr
key={ticket.$id}
className="row"
onClick={(e) => {
if (isRowActivation(e)) navigate(path, { state: { listSearch } })
}}
>
<td>
<div className="cell-title">
<Link className="row-link" to={path} state={{ listSearch }}>
<span className="id">{ticketNumber(ticket)}</span>
<span>{ticket.topic || ticket.title || 'Kein Betreff'}</span>
</Link>
<UrgentPill priority={ticket.priority} />
</div>
<div className="cell-sub">
{ticket.customerName || 'Unbekannt'}
{ticket.customerLocation ? ` · ${ticket.customerLocation}` : ''}
{showType && ticket.type ? ` · ${ticketTypeLabel(ticket.type)}` : ''}
</div>
</td>
<td><StatusPill status={ticket.status} /></td>
<td className="hide-sm">{assignee.none ? <span className="faint">{assignee.text}</span> : assignee.text}</td>
<td className="hide-md">{appointmentText(ticket) || '–'}</td>
<td className="hide-md right num">{minutesCellText(ticket, index, byWoid)}</td>
<td className="hide-sm right">{elapsedLabel(ticket)}</td>
</tr>
)
}),
[workorders, employees, byWoid, version, listSearch, navigate, showType]
)
let content
// Ladezustand, solange keine Zeilen da sind (erstes Laden und Filterwechsel auf eine leere Liste); mit Zeilen bleiben die alten stehen
if (loading && workorders.length === 0) {
content = <div className="loading">Wird geladen …</div>
} else if (error) {
content = <p className="msg err" role="alert">Fehler: {error}</p>
} else if (workorders.length === 0) {
content = (
<EmptyState
title="Keine Tickets"
hint="Keine Tickets passen zu Suche und Filter. Erledigte, stornierte und Akquise-Tickets sind in der Standardansicht ausgeblendet."
action={<Button type="button" onClick={openCreate}>Neues Ticket</Button>}
/>
)
} else {
content = (
<>
<table className="table">
<thead>
<tr>
<th>Ticket</th>
<th>Status</th>
<th className="hide-sm">Zuständig</th>
<th className="hide-md">Termin</th>
<th className="hide-md right num">Zeit</th>
<th className="hide-sm right">Erstellt</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
<div className="table-foot">
<div>
{countText(workorders.length, Boolean(state.search))}
{loading && <span className="faint"> · Wird geladen …</span>}
{capped && <span className="faint"> · Zeit nur für die ersten {TIME_MAX_ROWS} Zeilen berechnet</span>}
</div>
<div>Neueste zuerst</div>
</div>
{hasMore && (
<div className="list-more">
<Button type="button" disabled={loading} onClick={loadMore}>Mehr laden</Button>
</div>
)}
</>
)
}
return (
@@ -80,86 +210,60 @@ export default function TicketsPage() {
<PageHeader
title="Tickets"
subtitle="Alle Arbeitsaufträge auf einen Blick"
actions={
<>
<Button variant="ghost" onClick={() => setShowAdvanced((s) => !s)}><FaSliders /> Filter</Button>
<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>
</>
}
actions={<Button variant="primary" type="button" onClick={openCreate}>Neues Ticket</Button>}
/>
<Card pad className="mb-2" style={{ marginBottom: 16 }}>
<form className="toolbar" onSubmit={applyFilters} style={{ marginBottom: showAdvanced ? 16 : 0 }}>
<input
type="search"
className="form-control search-input"
placeholder="Suchen: Kunde, Betreff, Angefragt von oder WOID"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
<Button variant="primary" type="submit">Anwenden</Button>
</form>
<Tabs
tabs={tabs}
active={state.tab}
onChange={(id) => { if (id !== state.tab) setFilter({ tab: id }) }}
/>
{showAdvanced && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 16 }}>
<div className="flex gap-3 wrap" style={{ alignItems: 'center' }}>
<select aria-label="Typ" className="form-control" style={{ maxWidth: 220 }} value={typeId} onChange={(e) => setTypeId(e.target.value)}>
{TYPE_FILTERS.map((filter) => (
<option key={filter.id} value={filter.id}>{filter.label}</option>
))}
</select>
<label className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 14 }}>
<input type="checkbox" checked={urgentOnly} onChange={(e) => setUrgentOnly(e.target.checked)} />
Nur dringende
</label>
</div>
<div style={{ marginTop: 16 }}>
<div className="flex gap-2 wrap status-filter-actions" style={{ alignItems: 'center', marginBottom: 8 }}>
<span className="muted" style={{ fontSize: 13, fontWeight: 600 }}>Status</span>
<button type="button" onClick={() => setGroups(DEFAULT_STATUS_GROUPS)} style={linkButton}>Standard</button>
<button type="button" onClick={() => setGroups(ALL_GROUPS)} style={linkButton}>Alle</button>
</div>
<div className="flex gap-3 wrap status-filter">
{STATUS_GROUPS.map((group) => (
<label key={group.id} className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 14 }}>
<input type="checkbox" checked={groups.includes(group.id)} onChange={() => toggleGroup(group.id)} />
{group.label}
</label>
))}
</div>
</div>
</div>
)}
</Card>
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : error ? (
<Card><div className="text-red">Fehler: {error}</div></Card>
) : workorders.length === 0 ? (
<Card>
<EmptyState
title="Keine Tickets"
hint="Keine Tickets passen zu Suche und Filter. Erledigte, stornierte und Akquise-Tickets sind in der Standardansicht ausgeblendet."
action={<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>}
/>
</Card>
) : (
<div className="tickets-list">
{workorders.map((ticket) => (
<TicketCard key={ticket.$id} ticket={ticket} onUpdate={handleUpdate} />
<form
className="toolbar"
onSubmit={(e) => {
e.preventDefault()
commitSearch(searchText)
}}
>
<input
type="search"
className="input search"
aria-label="Tickets durchsuchen"
placeholder="Suchen: Kunde, Betreff, Angefragt von oder WOID"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
<select
className="select select-quiet"
aria-label="Art"
value={state.typeId}
onChange={(e) => setFilter({ typeId: e.target.value })}
>
{TYPE_FILTERS.map((filter) => (
<option key={filter.id} value={filter.id}>{filter.label}</option>
))}
</div>
)}
</select>
<span className="grow" />
<label className="check">
<input
type="checkbox"
checked={state.urgentOnly}
onChange={(e) => setFilter({ urgentOnly: e.target.checked })}
/>
Nur dringende
</label>
</form>
{/* Bei einer Suche kommen alle Treffer auf einmal */}
{!filters.search && workorders.length > 0 && workorders.length >= limit && (
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Button variant="ghost" onClick={loadMore}>Mehr laden <FaAngleDown /></Button>
</div>
)}
{content}
<CreateTicketModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} onCreate={handleCreate} customers={customers} />
<CreateTicketModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onCreate={handleCreate}
customers={customers}
employees={employees}
/>
</div>
)
}
+6 -6
View File
@@ -9,12 +9,12 @@
.area-row.head { color: var(--text-muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
.area-row.active { background: var(--accent-soft); }
.area-muted { color: var(--text-muted); font-size: 13px; }
.area-danger { color: #f87171; font-size: 13px; }
.area-warn { color: #fbbf24; font-size: 13px; }
.area-danger { color: var(--red-fg); font-size: 13px; }
.area-warn { color: var(--orange-fg); font-size: 13px; }
.area-summary { padding: 12px 16px; border-radius: var(--r-sm); background: var(--surface-2); margin-bottom: var(--sp-3); }
.area-summary.warn { background: rgba(245, 158, 11, 0.16); color: #fbbf24; }
.area-summary.warn { background: var(--orange-bg); color: var(--orange-fg); }
.area-message { padding: 10px 14px; border-radius: var(--r-sm); background: var(--accent-soft); margin-bottom: var(--sp-3); }
.area-error { padding: 10px 14px; border-radius: var(--r-sm); background: rgba(239, 68, 68, 0.14); color: #f87171; margin: var(--sp-2) 0; }
.area-error { padding: 10px 14px; border-radius: var(--r-sm); background: var(--red-bg); color: var(--red-fg); margin: var(--sp-2) 0; }
.area-kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; margin: 0; }
.area-kv dt { color: var(--text-muted); }
.area-kv dd { margin: 0; overflow-wrap: anywhere; }
@@ -46,7 +46,7 @@
white-space: pre-wrap; overflow-wrap: anywhere; user-select: text;
}
@media (max-width: 720px) {
.area-row { grid-template-columns: minmax(0, 1fr) !important; }
.area-rows .area-row { grid-template-columns: minmax(0, 1fr); }
.area-row.head { display: none; }
.funktionen-leer { display: none; }
.area-kv { grid-template-columns: minmax(0, 1fr); }
@@ -70,7 +70,7 @@
/* Nach verfuegbarer Breite stapeln (nicht nach Fensterbreite): mit ausgeklappter Sidebar sind Tablet quer oder ein
halbes Laptop-Fenster sonst zu schmal fuer die Spalten */
@container (max-width: 960px) {
.area-row { grid-template-columns: minmax(0, 1fr) !important; gap: 4px; }
.area-rows .area-row { grid-template-columns: minmax(0, 1fr); gap: 4px; }
.area-row.head { display: none; }
.funktionen-leer { display: none; }
}
+13 -6
View File
@@ -2,25 +2,32 @@
chat-composer.css des Kundenportals; hier Farben (--chat-*), Verlauf, Sprechblasen und die Liste. */
.woms-chat {
--chat-accent: var(--accent);
--chat-accent-hover: var(--accent-2);
--chat-accent-hover: var(--accent-hover);
--chat-accent-soft: var(--accent-soft);
--chat-accent-line: rgba(16, 185, 129, 0.5);
--chat-accent-line: rgba(17, 17, 17, 0.14);
--chat-accent-text: var(--accent);
--chat-on-accent: #04130d;
--chat-surface: var(--surface-2);
--chat-on-accent: #ffffff;
--chat-surface: var(--bg);
--chat-line: var(--line-strong);
--chat-muted: var(--text-2);
display: flex; flex-direction: column; gap: var(--sp-2); min-width: 0;
}
.woms-chat .chat-messages {
display: flex; flex-direction: column; gap: 8px; min-height: 280px; max-height: 60vh; overflow-y: auto;
padding: 12px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface);
padding: 12px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg);
}
.woms-chat .chat-bubble {
align-self: flex-start; max-width: 78%; padding: 8px 12px; border-radius: 14px;
background: var(--surface-2); color: var(--text); overflow-wrap: anywhere;
}
.woms-chat .chat-bubble.mine { align-self: flex-end; background: var(--accent-soft); border: 1px solid rgba(16, 185, 129, 0.35); }
.woms-chat .chat-bubble.mine { align-self: flex-end; background: var(--accent-soft); border: 1px solid var(--line); }
.woms-chat .chat-meta { display: block; margin-top: 4px; font-size: 11.5px; color: var(--text-muted); }
.woms-chat .chat-notice { font-size: 12px; }
/* Meldungen des Eingabebausteins (Stile aus chat-composer.css des Portals): Fehler rot, Info grau, beides lesbar auf Weiss */
.woms-chat .chat-composer-note,
.woms-chat .chat-tray-item.is-error .chat-file-size,
.woms-chat .chat-audio.is-error .chat-audio-time { color: var(--red-fg); }
.woms-chat .chat-composer-note.is-info { color: var(--text-2); }
/* Eingabefeld: Grundstil wie im frueheren Admin-Panel (chat-composer.css setzt nur das Layout) */
.woms-chat .chat-input-row textarea {
resize: none; min-height: 46px; max-height: 130px; padding: 11px 14px; border-radius: 14px;
+685 -996
View File
File diff suppressed because it is too large. Load diff
+4 -10
View File
@@ -1,16 +1,16 @@
/* Serverpuls (Admin) */
.sp-page { display: flex; flex-direction: column; gap: var(--sp-4); }
.sp-error { background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.4); color: #fca5a5; padding: var(--sp-3) var(--sp-4); border-radius: var(--r-md); }
.sp-error { background: var(--red-bg); border: 1px solid var(--danger-ring); color: var(--red-fg); padding: var(--sp-3) var(--sp-4); border-radius: var(--r-md); }
.sp-muted { color: var(--text-muted); }
.sp-small { font-size: 12px; }
.sp-head { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-4); }
.sp-level-medium { border-color: rgba(245,158,11,.5); }
.sp-level-critical { border-color: rgba(239,68,68,.6); }
.sp-level-medium { border-color: var(--orange-fg); }
.sp-level-critical { border-color: var(--danger); }
.sp-head-top { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
.sp-headline { font-size: 18px; }
.sp-age { margin-left: auto; font-size: 13px; color: var(--text-muted); }
.sp-age-stale { color: #fbbf24; background: rgba(245,158,11,.14); padding: 2px 10px; border-radius: var(--r-pill); }
.sp-age-stale { color: var(--orange-fg); background: var(--orange-bg); padding: 2px 10px; border-radius: var(--r-pill); }
.sp-alerts { list-style: none; margin: var(--sp-3) 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.sp-alert { display: flex; flex-wrap: wrap; gap: var(--sp-3); align-items: baseline; padding: 8px 12px; border-radius: var(--r-sm); background: var(--surface-2); border-left: 3px solid var(--text-faint); }
.sp-alert-critical { border-left-color: var(--danger); }
@@ -66,9 +66,3 @@
.sp-cleanup-grid strong { font-size: 20px; }
.sp-cleanup-list { list-style: none; margin: var(--sp-3) 0; padding: 0; }
.sp-cleanup-list li { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
.side-dot { width: 8px; height: 8px; border-radius: 50%; margin-left: auto; flex-shrink: 0; }
.side-dot-medium { background: #f59e0b; }
.side-dot-critical { background: #ef4444; }
.side.collapsed .side-dot { position: absolute; margin: 0; transform: translate(12px, -10px); }
.side-link { position: relative; }