Compare commits
15
Commits
f3b73ca1dd
...
f6b329b208
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6b329b208 | ||
|
|
ed8bdba015 | ||
|
|
504571c0ce | ||
|
|
ff999ae1b3 | ||
|
|
30b3caee34 | ||
|
|
2ecc0b627a | ||
|
|
b544b83da0 | ||
|
|
200d68f20d | ||
|
|
39dfb386ac | ||
|
|
6eb5ab3679 | ||
|
|
ab42918bbc | ||
|
|
0b53fc825a | ||
|
|
9fec381056 | ||
|
|
32746727ed | ||
|
|
54fc44ed11 |
No files matched your search
+7
-2
@@ -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])
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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 }) : '–'
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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), [])
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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' })), [])
|
||||
})
|
||||
Reference in new issue
Block a user