Compare commits

...
15 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 f6b329b208 Tickets: nur eine gelesene Uhrzeitspanne bleibt im Termin zusammen, Rohtext bricht weiter frei um
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:03:14 +00:00
ClaudeandClaude Sonnet 5.5 ed8bdba015 Tickets: Abschlussfixes P2 - Termin ohne Umbruch in der Zeitspanne, Art in der Liste, Laden statt Leerzustand, Unterstuetzung entsperrt bei Fehler, Aufraeumen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 06:03:14 +00:00
ClaudeandClaude Sonnet 5.5 504571c0ce Tickets: Ticketnummer bricht nicht um, Kontrast in der markierten Zeile, Suchfeld breiter
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 06:03:14 +00:00
ClaudeandClaude Sonnet 5.5 ff999ae1b3 Tickets: TicketCard, Dropdowns, statusClass und Altklassen (.tcard, .dropdown, .ticket-tab, .chevron) geloescht
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 06:03:09 +00:00
ClaudeandClaude Sonnet 5.5 30b3caee34 Tickets: Links auf Tickets nutzen ticketPath (Planboard, Zuordnen, Services, Funktionen)
Auch drei Kommentare mit dem Altlink-Muster umformuliert (useWorkorders, ticketListUrl, TicketsPage), sonst bleibt die Pruefung auf den Altlink nicht leer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 06:03:09 +00:00
ClaudeandClaude Sonnet 5.5 2ecc0b627a Tickets: Liste als Tabelle mit Reitern, Zaehlern, Zeit-Spalte und URL-Zustand; Suche per Tipp-Pause
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 06:02:55 +00:00
ClaudeandClaude Sonnet 5.5 b544b83da0 Tickets: Ticket als eigene Seite mit Route /tickets/:ref/:tab? (Facts, Reiter, Dialoge verdrahtet)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 06:02:55 +00:00
ClaudeandClaude Opus 5.5 200d68f20d Tickets: vier Ticket-Dialoge auf Dialog (Esc, Sperre beim Speichern), Unterstuetzung als Feldblock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:02:55 +00:00
ClaudeandClaude Opus 5.5 39dfb386ac Dialog: Esc nur fuer den obersten Dialog, Schliessen ohne Ereignis, Fokus zurueck auch nach autoFocus
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:02:55 +00:00
ClaudeandClaude Opus 5.5 6eb5ab3679 Tickets: Arbeitsblatt-Zeilen (.ws), Kennzahlen, Dringend-Schalter und Weitere Tickets im neuen Look
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:02:55 +00:00
ClaudeandClaude Opus 5.5 ab42918bbc Tickets: Hooks useWorkorder, Zaehler und Zeitsummen; useWorkorders verwirft veraltete Antworten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:02:55 +00:00
ClaudeandClaude Opus 5.5 0b53fc825a Tickets: Zeit-Spalte (Bloecke, 3 parallel, 200 Zeilen), Ticket-Suche per woid oder ID, Anfrage-Zaehler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:02:55 +00:00
ClaudeandClaude Opus 5.5 9fec381056 Tickets: Arbeitsblatt-Folgen und Zeilen-Logik als getestete Lib (Akquise-Ergebnis, Summen, Abo-Regel)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:02:54 +00:00
ClaudeandClaude Opus 5.5 32746727ed Tickets: Anzeige-Helfer fuer Termin, Faellig, Zustaendig und Status-Auswahl (Altdaten sicher)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:02:54 +00:00
ClaudeandClaude Opus 5.5 54fc44ed11 Tickets: ticketPath, Listenzustand in der URL (Reiter, Art, Dringend, Suche, limit), woid-Umleitung, Zeilenklick-Helfer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:02:54 +00:00
46 changed files with 2213 additions and 1292 deletions

No files matched your search

+7 -2
View File
@@ -5,6 +5,7 @@ import Sidebar from './components/Navbar'
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'
@@ -48,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>} />
@@ -87,10 +89,13 @@ function AppContent() {
window.scrollTo(0, 0)
}, [location.pathname])
// Esc schliesst die Schublade, nur solange sie offen ist
// 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.key === 'Escape') setMenuOpen(false) }
const onKey = (e) => {
if (e.defaultPrevented) return
if (e.key === 'Escape') setMenuOpen(false)
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [menuOpen])
+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: 'var(--text-2)', 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: 'var(--text-2)', 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>
)
}
+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>
)
}
+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 -329
View File
@@ -1,342 +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'
// Randfarbe je Zustand (worksheetTone): orange = Git-Zeit fehlt, gruen = Git-Zeit eingetragen. Der Kopf ist fuer alle Zustaende
// grau (Schrift --text auf --bg-subtle); P2 baut die Liste um.
const TONES = {
'git-open': { border: 'var(--orange-fg)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' },
'git-done': { border: 'var(--green-fg)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' },
comment: { border: 'var(--blue-fg)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' },
work: { border: 'var(--text-2)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' },
}
// Hinweise im Kopf: graue Badge-Toene
const headerBadgeStyle = { background: 'var(--grey-bg)', color: 'var(--grey-fg)', marginLeft: '8px' }
const headerButtonStyle = {
background: 'var(--bg)',
color: 'var(--text)',
border: '1px solid var(--line-strong)',
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 = 'var(--shadow-btn)'
}} onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'none'
}}>
{/* 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: 'var(--text)',
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: 'var(--violet-bg)', color: 'var(--violet-fg)' }}>
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: 'var(--grey-bg)',
color: 'var(--grey-fg)',
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 ? 'var(--violet-bg)' : 'var(--bg)',
color: ws.countsToPlan ? 'var(--violet-fg)' : 'var(--text)',
border: '1px solid var(--line-strong)',
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: 'var(--green-fg)' }} />
<strong>{ws.employeeName}</strong>
{ws.employeeShort && (
<span className="badge ms-2" style={{
background: 'var(--green-bg)',
color: 'var(--green-fg)'
}}>{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: 'var(--green-fg)' }} />
<strong className="fs-5" style={{ color: 'var(--green-fg)' }}>{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: 'var(--grey-bg)',
color: 'var(--grey-fg)',
fontSize: '0.85rem'
}}>{serviceTypeLabel(ws.serviceType)}</span>
</div>
{/* Status-Änderung */}
{ws.oldStatus !== ws.newStatus && (
<div className="mb-3 p-3 rounded-3" style={{
background: 'var(--bg-subtle)'
}}>
<FaExchangeAlt className="me-2" style={{ color: 'var(--green-fg)' }} />
<span className="text-muted">Status:</span>{' '}
<span className="badge" style={{ background: 'var(--grey-bg)', color: 'var(--grey-fg)' }}>{statusLabel(ws.oldStatus)}</span>
<span className="mx-2" style={{ color: 'var(--green-fg)' }}>→</span>
<span className="badge" style={{ background: 'var(--green-bg)', color: 'var(--green-fg)' }}>{statusLabel(ws.newStatus)}</span>
</div>
)}
{/* Details */}
<div className="mt-3 p-3 rounded-3" style={{
background: 'var(--bg-subtle)',
border: '1px solid var(--line)'
}}>
<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: 'var(--text-2)', 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>
)
}
+13 -3
View File
@@ -2,10 +2,14 @@ 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. Esc greift nur, wenn kein innerer Baustein die Taste schon behandelt hat
* 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).
@@ -23,12 +27,18 @@ export default function Dialog({ title, onClose, busy = false, wide = false, as
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)
return () => {
document.removeEventListener('keydown', onKeyDown)
const index = openDialogs.indexOf(latest)
if (index !== -1) openDialogs.splice(index, 1)
}
}, [])
return (
@@ -44,7 +54,7 @@ export default function Dialog({ title, onClose, busy = false, wide = false, as
>
<div className="dialog-head">
<h3 id={titleId}>{title}</h3>
<button type="button" className="dialog-close" aria-label="Schließen" disabled={busy} onClick={onClose}>
<button type="button" className="dialog-close" aria-label="Schließen" disabled={busy} onClick={() => onClose()}>
×
</button>
</div>
+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])
+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])
+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 }) : '–'
}
+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))
}
+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'
}
+9 -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'] },
@@ -66,20 +74,6 @@ 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
@@ -106,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)
}
+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
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { PageHeader, Card, Button } from '../components/ui'
import { useWorkorders } from '../hooks/useWorkorders'
import { useEmployees } from '../hooks/useEmployees'
import { groupAppointmentsByDay, countInRange, dayKey } from '../lib/planboard'
import { ticketPath } from '../lib/ticketRoutes'
/* Termine haengen an den Tickets (startDate/startTime/endTime/assignedTo);
die Aufbereitung steckt in src/lib/planboard.js. */
@@ -79,7 +80,7 @@ export default function PlanboardPage() {
key={entry.id}
type="button"
title={`Ticket #${entry.woid} öffnen`}
onClick={() => navigate(`/tickets?woid=${encodeURIComponent(entry.woid)}`)}
onClick={() => navigate(ticketPath(entry.woid || entry.id))}
style={{
textAlign: 'left',
border: '1px solid var(--border)',
+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>
)
}
+51 -69
View File
@@ -523,46 +523,6 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
.page-subtitle { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
.page-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
/* --- Ticket Card (ersetzt Tabelle) --- */
.tickets-list { display: flex; flex-direction: column; gap: var(--sp-3); }
.tcard {
background: var(--surface-1); border: 1px solid var(--border);
border-radius: var(--r-md); overflow: visible; transition: border-color 0.15s ease;
}
.tcard:hover { border-color: var(--border-strong); }
.tcard.open { border-color: var(--border-accent); }
.tcard-detail { border-radius: 0 0 var(--r-md) var(--r-md); }
.tcard-main {
display: grid; grid-template-columns: 92px 1fr auto; gap: var(--sp-4);
padding: var(--sp-4); align-items: center; cursor: pointer;
}
.tcard-woid { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
.tcard-woid .id { font-weight: 800; font-size: 15px; color: var(--accent); }
.tcard-woid .age { font-size: 11px; color: var(--text-faint); }
.tcard-body { min-width: 0; }
.tcard-row1 { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: 4px; }
.tcard-customer { font-weight: 700; }
.tcard-loc { color: var(--text-muted); font-size: 13px; }
.tcard-topic { color: var(--text-muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tcard-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
/* Eine Zeile ueber der Arbeitsblatt-Liste */
.worksheet-summary {
display: flex; flex-wrap: wrap; gap: 6px 20px; align-items: baseline;
padding: 10px 14px; margin-bottom: 16px; font-size: 14px; color: var(--text-muted);
background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-md);
}
.worksheet-summary strong { color: var(--text); }
.worksheet-summary .open { color: var(--orange-fg); font-weight: 700; }
.tcard-side { display: flex; align-items: center; gap: var(--sp-3); }
.tcard-detail { border-top: 1px solid var(--border); padding: var(--sp-4); background: var(--surface-1); }
.chevron { color: var(--text-muted); transition: transform 0.2s ease; }
.chevron.up { transform: rotate(180deg); }
@media (max-width: 720px) {
.tcard-main { grid-template-columns: 64px 1fr; }
.tcard-side { grid-column: 1 / -1; justify-content: space-between; }
}
/* --- Generic data list rows (Kunden/Finanzen/Dokumente) --- */
.list { display: flex; flex-direction: column; }
@@ -600,18 +560,6 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
/* Planboard (bis P3): die 7-Spalten-Woche scrollt waagerecht im Container, nicht die Seite */
.planboard-scroll { overflow-x: auto; }
.automatic-assignment-note {
padding: 12px 14px;
border-left: 2px solid var(--accent);
background: var(--surface-2);
}
.automatic-assignment-note p {
margin: 5px 0 0;
color: var(--text-muted);
font-size: 13px;
line-height: 1.45;
}
/* Neues Ticket: Datum + Uhrzeit nebeneinander (heller Kalender, kein color-scheme: dark) */
.datetime-pair { display: flex; gap: 8px; }
.datetime-pair .form-control, .datetime-pair .input { flex: 1; min-width: 0; }
@@ -1014,6 +962,56 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
.fin-bank-facts > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
}
/* ===== Tickets (P2) ===== */
/* Arbeitsblatt-Zeilen, Zeilenlinks, Nachladen und Unterstuetzungs-Zeile. Steht direkt vor KOMPAT: gewinnt ueber die Bausteinregeln */
/* oberhalb durch die Reihenfolge, nie durch eine Erzwingung. */
/* ---------- Arbeitsblaetter ---------- */
.ws { padding: 16px 0; border-bottom: 1px solid var(--line); display: grid; grid-template-columns: 148px minmax(0, 1fr) auto; gap: 4px 24px; }
.ws:first-of-type { border-top: 1px solid var(--line); }
.ws-when { color: var(--text-2); font-size: 13px; line-height: 20px; }
.ws-when .d { color: var(--text); font-weight: 500; font-size: 14px; }
.ws-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ws-head .who { font-weight: 600; overflow-wrap: anywhere; }
.ws-text { color: var(--text); margin-top: 4px; max-width: 640px; white-space: pre-wrap; overflow-wrap: anywhere; }
.ws-support { margin-top: 8px; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
.ws-acts { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 8px; font-size: 13px; }
.ws-time { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.ws-time .t { font-weight: 600; }
.ws.comment .ws-text { color: var(--text-2); }
button.link, .abo-toggle { background: none; border: 0; padding: 0; font: inherit; cursor: pointer; }
button.link { font-weight: 500; } /* font: inherit setzt sonst das Gewicht von .link zurueck */
.abo-toggle { font-size: 12px; font-weight: 500; color: var(--text-2); }
.abo-toggle:hover { color: var(--text); }
.abo-toggle.on { color: var(--blue-fg); }
/* ---------- Zeilenlinks, Nachladen, Suche ---------- */
.row-link { display: inline-flex; align-items: baseline; gap: 8px; min-width: 0; color: inherit; overflow-wrap: anywhere; }
/* Die Nummer bleibt eine Zeile; nur der Betreff darf umbrechen (sonst schrumpft .id auf ein Zeichen) */
.row-link .id { flex: none; white-space: nowrap; }
.table td .cell-sub { overflow-wrap: anywhere; }
/* Auf der markierten Zeile (--bg-subtle) reicht das helle Grau von .id und .faint nicht fuer den Kontrast */
.table tr.row:hover .id, .table tr.row:hover .faint { color: var(--text-2); }
.list-more { display: flex; justify-content: center; padding: 8px 0 24px; }
/* Der lange Platzhalter wird am Handy mit "…" gekuerzt statt hart abgeschnitten */
.toolbar .search { text-overflow: ellipsis; }
@media (min-width: 861px) {
.toolbar .search { width: 400px; }
}
/* ---------- Unterstuetzung im Dialog ---------- */
.supporter-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px 12px; align-items: center; margin-top: 8px; }
.supporter-row .sup-meta { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; font-size: 13px; color: var(--text-2); }
/* Kaestchen im Dialog stehen in einer Spalte (flex): ohne das waere die ganze Zeile klickbar */
.dialog-body > .check, .supporters-editor > .check { align-self: flex-start; }
@media (max-width: 860px) {
.ws { grid-template-columns: minmax(0, 1fr) auto; }
.ws-when { grid-column: 1 / -1; display: flex; gap: 8px; }
.supporter-row { grid-template-columns: minmax(0, 1fr); }
}
/* ===== ENDE Tickets (P2) ===== */
/* ===== KOMPAT (bis P10) ===== */
/* Uebergangsschicht: alte Klassen im neuen Look, damit Seiten und Dialoge, die noch nicht umgebaut sind, weiss, kastenlos */
/* und lesbar bleiben. Steht am Dateiende und gewinnt dort gegen die Altregeln und gegen die anderen Stylesheets (global.css */
@@ -1087,13 +1085,6 @@ select.form-control {
}
.overlay-close:hover { color: var(--text); background: var(--bg-hover); }
/* Reiter (Tabs.jsx): Unterstrich statt Kasten wie .tabs / .tab */
.ticket-tabs { display: flex; gap: 24px; margin-bottom: 16px; border-bottom: 1px solid var(--line); overflow-x: auto; scrollbar-width: none; }
.ticket-tab { margin-bottom: -1px; padding: 8px 0 10px; border-bottom: 2px solid transparent; color: var(--text-2); font-weight: 500; white-space: nowrap; }
.ticket-tab:hover { color: var(--text); }
.ticket-tab-active, .ticket-tab-active:hover { border-bottom-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
.ticket-tab-content { min-height: 120px; }
/* Umschalter */
.seg button.active { background: var(--bg); color: var(--text); box-shadow: 0 1px 2px rgba(26,31,54,.12), 0 0 0 1px rgba(26,31,54,.04); }
@@ -1109,7 +1100,7 @@ select.form-control {
border-radius: var(--radius); background: var(--grey-bg); color: var(--grey-fg); font-weight: 600;
}
/* Neutral: Karte, Raster, Aufklappmenue */
/* Neutral: Karte, Raster */
.card { margin-bottom: 16px; background: none; border: 0; border-radius: 0; color: var(--text); }
.card-header { padding: 0 0 8px; background: none; border-bottom: 1px solid var(--line); color: var(--text); font-weight: 600; }
.card-body { padding: 12px 0; color: var(--text); }
@@ -1117,15 +1108,6 @@ div.row { display: flex; flex-wrap: wrap; margin: 0 -8px; }
.col { padding: 0 8px; }
.col-6 { width: 50%; }
@media (max-width: 768px) { .col-6 { width: 100%; } }
.dropdown { position: relative; display: inline-block; width: 100%; text-align: center; }
.dropdown .btn { width: 100%; padding: 0 8px; font-size: 12px; box-shadow: none; }
.dropdown-content {
display: none; position: absolute; left: 50%; transform: translateX(-50%); z-index: 100; min-width: 160px;
background: var(--bg); border-radius: var(--radius); box-shadow: var(--shadow-pop); text-align: left;
}
.dropdown:hover .dropdown-content { display: block; }
.dropdown-item { display: block; padding: 8px 16px; color: var(--text); cursor: pointer; }
.dropdown-item:hover { background: var(--bg-hover); }
/* Laden: neutraler grauer Ring */
.spinner { width: 40px; height: 40px; margin: 0 auto; border: 4px solid var(--line); border-top-color: var(--text-2); border-radius: 50%; animation: spin 1s linear infinite; }
+11
View File
@@ -0,0 +1,11 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { createRequestGuard } = await loadModule('src/lib/requestGuard.js')
test('nur die jüngste Anfrage gilt', () => {
const g = createRequestGuard()
const a = g.next(); const b = g.next()
assert.equal(g.isCurrent(a), false)
assert.equal(g.isCurrent(b), true)
})
+36
View File
@@ -0,0 +1,36 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { isRowActivation } = await loadModule('src/lib/rowClick.js')
const TAGS = ['a', 'button', 'select', 'input', 'textarea', 'label']
// Ereignis ohne DOM: closest liefert nur dann einen Treffer, wenn der Selektor das Tag nennt
function click({ target = 'td', outside = false, ...over } = {}) {
const row = { contains: (el) => el?.inRow === true }
const hit = TAGS.includes(target) ? { inRow: !outside } : null
return {
target: { closest: (sel) => (hit && sel.split(',').map((s) => s.trim()).includes(target) ? hit : null) },
currentTarget: row, button: 0, defaultPrevented: false, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...over,
}
}
test('Linksklick auf Zelle oder Zeile aktiviert die Zeile', () => {
assert.equal(isRowActivation(click()), true)
assert.equal(isRowActivation(click({ target: 'tr' })), true)
})
test('Klick auf a, button, select, input, textarea oder label aktiviert nicht', () => {
for (const tag of TAGS) assert.equal(isRowActivation(click({ target: tag })), false, tag)
})
test('Mittelklick, Modifikatortasten und behandelte Klicks aktivieren nicht', () => {
assert.equal(isRowActivation(click({ button: 1 })), false)
for (const key of ['metaKey', 'ctrlKey', 'shiftKey', 'altKey']) assert.equal(isRowActivation(click({ [key]: true })), false, key)
assert.equal(isRowActivation(click({ defaultPrevented: true })), false)
})
test('ein interaktiver Vorfahre außerhalb der Zeile zählt nicht', () => {
assert.equal(isRowActivation(click({ target: 'label', outside: true })), true)
})
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const m = await loadModule('src/lib/ticketListUrl.js')
const { LIST_TABS, LIST_DEFAULTS, parseListParams: parse, serializeListParams: ser, withFilter, nextLimit, listFilters, countFilters, woidRedirectTarget } = m
const { TYPE_FILTERS } = await loadModule('src/lib/ticketStatus.js')
test('Reiter und Art-Auswahl nach Spec', () => {
assert.deepEqual(LIST_TABS.map((t) => [t.id, t.label]), [['aktiv', 'Aktiv'], ['open', 'Offen'], ['working', 'In Arbeit'], ['waiting', 'Wartet auf Kunde'], ['review', 'Prüfung'], ['done', 'Erledigt'], ['cancelled', 'Storniert'], ['alle', 'Alle']])
assert.deepEqual(TYPE_FILTERS.map((f) => f.label), ['Aufträge', 'Webpage', 'Migration', 'Projekt', 'Akquise', 'Aufträge und Akquise'])
})
test('parse: Standard, gültige und ungültige Werte', () => {
assert.deepEqual(parse(''), LIST_DEFAULTS)
assert.deepEqual(parse('?status=done&art=all&dringend=1&q=%20Müller%20&limit=30'), { tab: 'done', typeId: 'all', urgentOnly: true, search: 'Müller', limit: 30 })
assert.deepEqual(parse('status=foo&art=bar&dringend=ja&limit=abc'), LIST_DEFAULTS)
for (const [raw, want] of [['5', 10], ['0', 10], ['25', 25], ['500', 500], ['9999', 500], ['-3', 10], ['1e3', 10], ['', 10]]) assert.equal(parse('limit=' + raw).limit, want, raw)
})
test('serialize: Standardwerte werden nicht geschrieben, Rundlauf', () => {
assert.equal(ser(LIST_DEFAULTS).toString(), '')
assert.equal(ser({ ...LIST_DEFAULTS, tab: 'alle', limit: 20 }).toString(), 'status=alle&limit=20')
assert.equal(ser({ ...LIST_DEFAULTS, urgentOnly: true, search: 'a b' }).toString(), 'dringend=1&q=a+b')
for (const s of [LIST_DEFAULTS, { tab: 'done', typeId: 'Akquise', urgentOnly: true, search: 'x y', limit: 40 }]) assert.deepEqual(parse(ser(s)), s)
})
test('Filteränderung setzt limit zurück, Mehr laden +10 bis 500', () => {
for (const patch of [{ tab: 'done' }, { typeId: 'all' }, { urgentOnly: true }, { search: 'x' }]) assert.equal(withFilter({ ...LIST_DEFAULTS, limit: 50 }, patch).limit, 10)
assert.deepEqual([10, 495, 500].map((limit) => nextLimit({ limit })), [20, 500, 500])
})
test('listFilters und countFilters', () => {
assert.deepEqual(listFilters(LIST_DEFAULTS), { status: ['Open', 'Occupied', 'Assigned', 'Added Info', 'In Kontakt', 'Awaiting', 'Halted', 'In Test'], type: [], excludeType: 'Akquise', priority: [], search: undefined, limit: 10 })
assert.deepEqual(listFilters({ ...LIST_DEFAULTS, tab: 'alle', typeId: 'Akquise', urgentOnly: true, search: 'abc' }), { status: [], type: ['Akquise'], excludeType: undefined, priority: [3, 4], search: 'abc', limit: 10 })
assert.deepEqual(listFilters({ ...LIST_DEFAULTS, tab: 'done' }).status, ['Closed', 'Zugesagt'])
const s = { ...LIST_DEFAULTS, typeId: 'Webpage', urgentOnly: true, search: 'zzz', limit: 80 }
assert.deepEqual(countFilters(s, 'review'), { status: ['In Test'], type: ['Webpage'], excludeType: undefined, priority: [3, 4] })
for (const t of LIST_TABS) assert.deepEqual(countFilters(s, t.id).status, listFilters({ ...s, tab: t.id }).status, t.id)
})
test('woid-Umleitung hat Vorrang', () => {
assert.equal(woidRedirectTarget('?woid=100131'), '/tickets/100131')
assert.equal(woidRedirectTarget('?woid=%20100131%20&status=done&q=x'), '/tickets/100131')
assert.equal(woidRedirectTarget('?woid=a%2Fb'), '/tickets/a%2Fb')
for (const none of ['', '?woid=', '?status=done']) assert.equal(woidRedirectTarget(none), null)
})
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { ticketPath, ticketRef, resolveTicketTab, listBackPath } = await loadModule('src/lib/ticketRoutes.js')
test('ticketPath: woid, Zahl, $id, Reiter', () => {
assert.equal(ticketPath('100131'), '/tickets/100131')
assert.equal(ticketPath(100131), '/tickets/100131')
assert.equal(ticketPath('100131', 'details'), '/tickets/100131')
assert.equal(ticketPath('100131', 'arbeitsblaetter'), '/tickets/100131/arbeitsblaetter')
assert.equal(ticketPath('100131', 'unbekannt'), '/tickets/100131')
assert.equal(ticketPath('66f1c2a9000b1c2d3e4f'), '/tickets/66f1c2a9000b1c2d3e4f')
})
test('ticketPath: Sonderzeichen kodiert, leeres ref ergibt /tickets', () => {
assert.equal(ticketPath('a/b c?d#e'), '/tickets/a%2Fb%20c%3Fd%23e')
for (const empty of ['', ' ', null, undefined]) {
assert.equal(ticketPath(empty), '/tickets')
assert.equal(ticketPath(empty, 'arbeitsblaetter'), '/tickets')
}
})
test('ticketRef: woid vor $id, nie die Kurznummer', () => {
assert.equal(ticketRef({ woid: '100131', $id: 'abc' }), '100131')
assert.equal(ticketRef({ woid: '', $id: 'abcdefghij' }), 'abcdefghij')
assert.equal(ticketRef({ woid: ' ', $id: 'abcdefghij' }), 'abcdefghij')
assert.equal(ticketRef(null), '')
})
test('resolveTicketTab: unbekannt ergibt null', () => {
assert.equal(resolveTicketTab(undefined), 'details')
assert.equal(resolveTicketTab('arbeitsblaetter'), 'arbeitsblaetter')
for (const bad of ['Details', 'worksheets', '', 'x']) assert.equal(resolveTicketTab(bad), null)
})
test('listBackPath: nur Suchteile mit ?', () => {
assert.equal(listBackPath({ listSearch: '?status=done&q=x' }), '/tickets?status=done&q=x')
for (const bad of [undefined, null, {}, { listSearch: '' }, { listSearch: '?' }, { listSearch: 'evil' }, { listSearch: 42 }]) assert.equal(listBackPath(bad), '/tickets')
})
+9 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { statusTone } = await loadModule('src/lib/ticketStatus.js')
const { statusTone, statusSelectOptions } = await loadModule('src/lib/ticketStatus.js')
test('statusTone: Ton je gespeichertem Wert', () => {
for (const s of ['Open', '', undefined, null, 'Completed']) assert.equal(statusTone(s), '')
@@ -12,3 +12,11 @@ test('statusTone: Ton je gespeichertem Wert', () => {
for (const s of ['Closed', 'Zugesagt']) assert.equal(statusTone(s), 'green')
for (const s of ['Cancelled', 'Aborted', 'Abgesagt']) assert.equal(statusTone(s), 'plain')
})
test('statusSelectOptions: sechs Werte, aktueller Wert zusätzlich', () => {
const six = ['Open', 'Occupied', 'Awaiting', 'In Test', 'Closed', 'Cancelled']
for (const cur of ['Open', 'Closed', '', undefined, null]) assert.deepEqual(statusSelectOptions(cur).map((o) => o.value), six, String(cur))
assert.deepEqual(statusSelectOptions('In Kontakt').at(-1), { value: 'In Kontakt', label: 'In Kontakt' })
assert.deepEqual(statusSelectOptions('Assigned').at(-1), { value: 'Assigned', label: 'Eingeplant' })
assert.deepEqual(statusSelectOptions('Completed').at(-1), { value: 'Completed', label: 'Completed' })
})
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const t = await loadModule('src/lib/ticketTimes.js')
const tickets = (n, from = 100000) => Array.from({ length: n }, (_, i) => ({ $id: 'id' + i, woid: String(from + i) }))
const sup = JSON.stringify([{ employeeId: 'e', name: 'N', task: 'x' }])
test('chunk und planTimeLoad: erste 200 Zeilen, Blöcke zu 100', () => {
assert.deepEqual(t.chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]])
const plan = t.planTimeLoad(tickets(250), new Set())
assert.deepEqual(plan.blocks.map((b) => b.length), [100, 100])
assert.equal(plan.blocks[1].at(-1), '100199')
assert.equal(plan.capped, true)
assert.equal(t.planTimeLoad(tickets(200), new Set()).capped, false)
})
test('planTimeLoad: nur Neues, ohne woid, ohne Doppelte', () => {
const known = new Set(tickets(100).map((x) => x.woid))
assert.deepEqual(t.planTimeLoad(tickets(120), known).blocks, [tickets(20, 100100).map((x) => x.woid)])
assert.deepEqual(t.planTimeLoad([{ $id: 'a' }, { woid: '' }, { woid: '5' }, { woid: 5 }, { woid: '5' }], new Set()).blocks, [['5']])
assert.deepEqual(t.planTimeLoad(tickets(3), new Set(tickets(3).map((x) => x.woid))).blocks, [])
})
test('blockMinutes: Summe ohne Kommentare, mit Unterstützung, nur woids des Blocks', () => {
const docs = [
{ woid: '1', totalTime: 60 }, { woid: '1', totalTime: 30, supporters: sup }, { woid: '1', totalTime: 0, isComment: true },
{ woid: '2', totalTime: 45, isComment: true }, { woid: 7, totalTime: 15 }, { woid: '99', totalTime: 15 },
]
assert.deepEqual(t.blockMinutes(['1', '2', '3', '7'], docs, 6), { 1: 105, 2: 0, 3: 0, 7: 15 })
})
test('blockMinutes: abgeschnittene Blöcke liefern null, nie eine falsche Zahl', () => {
assert.equal(t.blockMinutes(['1'], Array.from({ length: 5000 }, () => ({ woid: '1', totalTime: 15 })), 5000), null)
assert.equal(t.blockMinutes(['1'], [{ woid: '1', totalTime: 15 }], 7), null)
assert.deepEqual(t.blockMinutes(['1'], [{ woid: '1', totalTime: 15 }], 1), { 1: 15 })
})
test('minutesCellText: Fehler, null, 0, ohne woid und jenseits von 200 zeigen –', () => {
const by = new Map([['1', 105], ['2', 0], ['3', null]])
assert.equal(t.minutesCellText({ woid: '1' }, 0, by), '1 h 45 min')
for (const x of [{ woid: '2' }, { woid: '3' }, { woid: '4' }, {}, { woid: '' }]) assert.equal(t.minutesCellText(x, 0, by), '–')
assert.equal(t.minutesCellText({ woid: '1' }, 199, by), '1 h 45 min')
assert.equal(t.minutesCellText({ woid: '1' }, 200, by), '–')
})
test('mapLimit: höchstens 3 gleichzeitig, Reihenfolge, Fehler je Element', async () => {
let running = 0, peak = 0
const out = await t.mapLimit([1, 2, 3, 4, 5, 6, 7], 3, async (n) => {
running += 1; peak = Math.max(peak, running)
await new Promise((r) => setTimeout(r, 5))
running -= 1
if (n === 4) throw new Error('x')
return n * 2
})
assert.equal(peak, 3)
assert.deepEqual(out.map((r) => r.ok), [true, true, true, false, true, true, true])
assert.deepEqual(out.filter((r) => r.ok).map((r) => r.value), [2, 4, 6, 10, 12, 14])
assert.deepEqual(await t.mapLimit([], 3, async () => 1), [])
})
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const v = await loadModule('src/lib/ticketView.js')
test('displayDate und displayTime: lesbar normalisiert, sonst Rohtext', () => {
for (const [raw, want] of [['2.5.2026', '02.05.2026'], ['31.02.2026', '31.02.2026'], ['2026-10-07T09:00:00.000Z', '2026-10-07T09:00:00.000Z'], [' ', ''], [undefined, '']]) assert.equal(v.displayDate(raw), want, String(raw))
for (const [raw, want] of [['0930', '09:30'], ['9:30', '09:30'], ['2560', '2560'], ['ab 9', 'ab 9'], ['', '']]) assert.equal(v.displayTime(raw), want, raw)
})
test('appointmentText: kein Rückfall, Rohtext bei Unlesbarem', () => {
const t = (o) => v.appointmentText(o)
assert.equal(t({ startDate: '2.5.2026', startTime: '0930', endTime: '1030' }), '02.05.2026, 09:30–10:30')
assert.equal(t({ startDate: '02.05.2026', startTime: '0930' }), '02.05.2026, 09:30')
assert.equal(t({ startDate: '02.05.2026', endTime: '1030' }), '02.05.2026')
assert.equal(t({ startDate: '31.02.2026', startTime: '0900' }), '31.02.2026, 09:00')
assert.equal(t({ startDate: '', startTime: '0900' }), '')
assert.equal(t({}), '')
assert.equal(v.dueText({ deadline: '5.10.2026' }), '05.10.2026')
assert.equal(v.dueText({}), '')
})
test('ticketNumber, createdDate, shortName', () => {
assert.equal(v.ticketNumber({ woid: '100131', $id: 'x' }), '100131')
assert.equal(v.ticketNumber({ $id: '66f1c2a9abcde' }), 'abcde')
assert.equal(v.ticketNumber({}), '')
assert.ok(v.createdDate({ $createdAt: '2026-10-01T10:00:00.000Z' }) instanceof Date)
assert.ok(v.createdDate({ createdAt: '2026-10-01T10:00:00.000Z' }) instanceof Date)
assert.equal(v.createdDate({ $createdAt: 'kaputt' }), null)
assert.equal(v.createdDate({}), null)
for (const [raw, want] of [['Kenso Grimm', 'Kenso G.'], [' Anna Maria Meier ', 'Anna M.'], ['Andrej', 'Andrej'], ['', '']]) assert.equal(v.shortName(raw), want)
})
const emps = [{ userId: 'u1', displayName: 'Kenso Grimm' }, { userId: 'u2', displayName: 'Andrej' }, { displayName: 'Ohne Login' }]
test('assigneeLabel: Niemand, Liste, assignedName, Unbekannt', () => {
assert.deepEqual(v.assigneeLabel({ assignedTo: '' }, emps), { text: 'Niemand', none: true })
assert.deepEqual(v.assigneeLabel({ assignedTo: 'u1' }, emps), { text: 'Kenso G.', none: false })
assert.equal(v.assigneeLabel({ assignedTo: 'u9', assignedName: 'Justin Nowak' }, emps).text, 'Justin N.')
assert.equal(v.assigneeLabel({ assignedTo: 'u9' }, emps).text, 'Unbekannt')
assert.equal(v.assigneeLabel({ assignedTo: 'u1', assignedName: 'Kenso Grimm' }, []).text, 'Kenso G.')
})
test('assigneeOptions: aktueller Wert bleibt immer wählbar', () => {
assert.deepEqual(v.assigneeOptions({ assignedTo: 'u1' }, emps), [{ value: '', label: 'Nicht zugewiesen' }, { value: 'u1', label: 'Kenso Grimm' }, { value: 'u2', label: 'Andrej' }])
assert.deepEqual(v.assigneeOptions({ assignedTo: 'u9', assignedName: 'Justin Nowak' }, emps).at(-1), { value: 'u9', label: 'Justin Nowak' })
assert.deepEqual(v.assigneeOptions({ assignedTo: 'u9' }, emps).at(-1), { value: 'u9', label: 'Unbekannte Person' })
assert.deepEqual(v.assigneeOptions({ assignedTo: '' }, []), [{ value: '', label: 'Nicht zugewiesen' }])
assert.equal(v.assigneeOptions({ assignedTo: 'u1' }, []).at(-1).label, 'Unbekannte Person')
})
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { lookupWorkorder } = await loadModule('src/lib/workorderLookup.js')
const fake = ({ byWoid = null, byId, byIdError } = {}) => {
const calls = []
return { calls, findByWoid: async (w) => { calls.push(['woid', w]); return byWoid }, getById: async (id) => { calls.push(['id', id]); if (byIdError) throw byIdError; return byId } }
}
test('erst woid, dann $id', async () => {
let d = fake({ byWoid: { $id: 'a', woid: '5' } })
assert.deepEqual(await lookupWorkorder(' 5 ', d), { status: 'ok', ticket: { $id: 'a', woid: '5' } })
assert.deepEqual(d.calls, [['woid', '5']])
d = fake({ byId: { $id: 'abc' } })
assert.deepEqual(await lookupWorkorder('abc', d), { status: 'ok', ticket: { $id: 'abc' } })
assert.deepEqual(d.calls, [['woid', 'abc'], ['id', 'abc']])
})
test('400 und 404 sind nicht gefunden, leeres ref ohne Abfrage', async () => {
for (const code of [400, 404]) assert.deepEqual(await lookupWorkorder('x', fake({ byIdError: { code } })), { status: 'notfound' })
const d = fake()
for (const empty of ['', ' ', null, undefined]) assert.deepEqual(await lookupWorkorder(empty, d), { status: 'notfound' })
assert.deepEqual(d.calls, [])
})
test('andere Fehler werden weitergereicht', async () => {
await assert.rejects(lookupWorkorder('x', fake({ byIdError: { code: 500, message: 'boom' } })))
await assert.rejects(lookupWorkorder('x', { findByWoid: async () => { throw new Error('netz') }, getById: async () => ({}) }), /netz/)
})
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { createWorksheetFlow, acquisitionOutcome } = await loadModule('src/lib/worksheetFlow.js')
async function run(ticket, newStatus, over = {}) {
const calls = { ticket: [], customer: [] }
const result = await createWorksheetFlow({
ticket, worksheetData: { newStatus }, user: { $id: 'u' },
createWorksheet: async () => over.result ?? { success: true },
updateTicket: async (id, data) => { calls.ticket.push([id, data]) },
updateCustomer: async (id, data) => { calls.customer.push([id, data]); if (over.customerFails) throw new Error('weg') },
now: () => new Date('2026-10-05T10:00:00Z'),
})
return { result, calls }
}
const akq = { $id: 't1', type: 'Akquise', status: 'In Kontakt', customerId: 'c1' }
test('Akquise + Zugesagt: Status zurückschreiben, Kunde fest', async () => {
const { calls } = await run(akq, 'Zugesagt')
assert.deepEqual(calls.ticket, [['t1', { status: 'Zugesagt' }]])
assert.deepEqual(calls.customer, [['c1', { customerStatus: 'customer', updatedAt: '2026-10-05T10:00:00.000Z' }]])
})
test('Akquise + Abgesagt: Kunde verloren', async () => {
assert.deepEqual((await run(akq, 'Abgesagt')).calls.customer, [['c1', { customerStatus: 'lost', updatedAt: '2026-10-05T10:00:00.000Z' }]])
})
test('Akquise + In Kontakt: keine Änderung', async () => {
assert.deepEqual((await run(akq, 'In Kontakt')).calls, { ticket: [], customer: [] })
assert.deepEqual((await run({ ...akq, status: 'Zugesagt' }, 'In Kontakt')).calls.customer, [])
})
test('Nicht-Akquise ändert nie den Kunden, Status nur bei Abweichung', async () => {
for (const status of ['Zugesagt', 'Abgesagt', 'Closed']) assert.deepEqual((await run({ ...akq, type: 'Webpage', status: 'Open' }, status)).calls.customer, [])
assert.deepEqual((await run({ ...akq, type: 'Webpage', status: 'Closed' }, 'Closed')).calls.ticket, [])
assert.deepEqual((await run({ ...akq, customerId: '' }, 'Zugesagt')).calls.customer, [])
})
test('Fehlschlag und Kundenfehler', async () => {
const failed = await run(akq, 'Zugesagt', { result: { success: false, error: 'x' } })
assert.deepEqual(failed, { result: { success: false, error: 'x' }, calls: { ticket: [], customer: [] } })
assert.equal((await run(akq, 'Zugesagt', { customerFails: true })).result.success, true)
assert.equal(acquisitionOutcome({ type: 'Akquise', customerId: 'c' }, 'Zugesagt'), 'customer')
})
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const w = await loadModule('src/lib/worksheetView.js')
const ws = (o = {}) => ({ totalTime: 60, isComment: false, countsToPlan: false, serviceType: 'Remote', ...o })
const sup = (n, name = 'Anna Meier') => JSON.stringify(Array.from({ length: n }, (_, i) => ({ employeeId: 'e' + i, name, task: 'DNS' })))
test('worksheetSums: Kommentare zählen nie, Abo nur countsToPlan, Zeiten offen', () => {
const list = [ws(), ws({ supporters: sup(1), countsToPlan: true }), ws({ isComment: true, totalTime: 0 }), ws({ serviceType: 'GIT', isComment: true, totalTime: 0 }), ws({ serviceType: 'GIT', totalTime: 30, startTime: '0900' })]
assert.deepEqual(w.worksheetSums(list), { totalMinutes: 180, aboMinutes: 90, openCount: 1 })
assert.deepEqual(w.worksheetSums([]), { totalMinutes: 0, aboMinutes: 0, openCount: 0 })
assert.deepEqual(w.worksheetSums([ws({ isComment: true, countsToPlan: true })]), { totalMinutes: 0, aboMinutes: 0, openCount: 0 })
})
test('showAboToggle: Regel bleibt exakt', () => {
assert.equal(w.showAboToggle(ws(), true), true)
assert.equal(w.showAboToggle(ws(), false), false)
assert.equal(w.showAboToggle(ws({ countsToPlan: true }), false), true)
assert.equal(w.showAboToggle(ws({ totalTime: 0 }), true), false)
assert.equal(w.showAboToggle(ws({ isComment: true }), true), false)
assert.equal(w.showAboToggle(ws({ totalTime: '45' }), true), true)
})
test('worksheetWhen: Start-, sonst Endzweig, Altwerte als Rohtext', () => {
assert.deepEqual(w.worksheetWhen({ startDate: '2.5.2026', startTime: '0930', endDate: '2.5.2026', endTime: '1100' }), { date: '02.05.2026', time: '09:30' })
assert.deepEqual(w.worksheetWhen({ startDate: '', startTime: '', endDate: '31.02.2026', endTime: '1405' }), { date: '31.02.2026', time: '14:05' })
assert.deepEqual(w.worksheetWhen({ startDate: '03.10.2026', startTime: '' }), { date: '03.10.2026', time: '' })
assert.deepEqual(w.worksheetWhen({}), { date: '', time: '' })
})
test('Art, Badge, Status danach, Zeit', () => {
assert.equal(w.worksheetKindLabel(ws({ isComment: true })), 'Kommentar')
assert.deepEqual(['Remote', 'On Site', 'Off Site', 'GIT'].map((s) => w.worksheetKindLabel(ws({ serviceType: s, isComment: s === 'GIT' }))), ['Fernwartung', 'Vor Ort', 'Fernwartung', 'GIT'])
assert.deepEqual(w.worksheetBadge(ws({ serviceType: 'GIT', isComment: true, totalTime: 0 })), { tone: 'orange', text: 'Zeit offen' })
assert.deepEqual(w.worksheetBadge(ws({ serviceType: 'GIT', startTime: '0900' })), { tone: 'green', text: 'Zeit eingetragen' })
assert.equal(w.worksheetBadge(ws()), null)
assert.equal(w.statusAfterLabel({ oldStatus: 'Open', newStatus: 'Occupied' }), 'Status danach: In Arbeit')
assert.equal(w.statusAfterLabel({ oldStatus: 'Closed', newStatus: 'Open' }), 'Status danach: Offen')
assert.equal(w.statusAfterLabel({ oldStatus: 'Open', newStatus: 'Completed' }), 'Status danach: Completed')
for (const same of [{ oldStatus: 'Open', newStatus: 'Open' }, { oldStatus: 'Open', newStatus: '' }, {}]) assert.equal(w.statusAfterLabel(same), '')
assert.deepEqual([ws({ isComment: true }), ws({ totalTime: 0 }), ws({ totalTime: 90 }), ws({ totalTime: '45' })].map(w.timeLabel), ['', '–', '1 h 30 min', '45 min'])
})
test('supportLines: je Person eine Zeile, Wörter wie bisher', () => {
assert.deepEqual(w.supportLines(ws({ supporters: sup(1) })), ['Unterstützung Anna · DNS · 30 min'])
assert.deepEqual(w.supportLines(ws({ totalTime: 75, supporters: sup(1, '') })), ['Unterstützung · DNS · 45 min'])
assert.deepEqual(w.supportLines(ws({ isComment: true, supporters: sup(1) })), [])
assert.deepEqual(w.supportLines(ws({ supporters: '{kaputt' })), [])
})