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 Logo from './components/Logo'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import TicketsPage from './pages/TicketsPage'
|
import TicketsPage from './pages/TicketsPage'
|
||||||
|
import TicketPage from './pages/TicketPage'
|
||||||
import PlanboardPage from './pages/PlanboardPage'
|
import PlanboardPage from './pages/PlanboardPage'
|
||||||
import ProjectsPage from './pages/ProjectsPage'
|
import ProjectsPage from './pages/ProjectsPage'
|
||||||
import AdminPage from './pages/AdminPage'
|
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="/login" element={user ? <Navigate to={safeReturnPath(location.state?.from)} replace /> : <LoginPage />} />
|
||||||
<Route path="/" element={<Navigate to="/tickets" replace />} />
|
<Route path="/" element={<Navigate to="/tickets" replace />} />
|
||||||
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
|
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/tickets/:ref/:tab?" element={<ProtectedRoute><TicketPage /></ProtectedRoute>} />
|
||||||
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
|
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
|
||||||
{/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */}
|
{/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */}
|
||||||
<Route path="/customers/chat" element={<ProtectedRoute><CustomerChatPage /></ProtectedRoute>} />
|
<Route path="/customers/chat" element={<ProtectedRoute><CustomerChatPage /></ProtectedRoute>} />
|
||||||
@@ -87,10 +89,13 @@ function AppContent() {
|
|||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
}, [location.pathname])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return undefined
|
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)
|
document.addEventListener('keydown', onKey)
|
||||||
return () => document.removeEventListener('keydown', onKey)
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
}, [menuOpen])
|
}, [menuOpen])
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { FaTimes } from 'react-icons/fa'
|
import { useScrollIntoView } from '../hooks/useScrollIntoView'
|
||||||
import { useEmployees } from '../hooks/useEmployees'
|
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||||
import {
|
import {
|
||||||
SERVICE_TYPES,
|
SERVICE_TYPES,
|
||||||
@@ -11,7 +10,7 @@ import {
|
|||||||
rankSuggestions,
|
rankSuggestions,
|
||||||
} from '../lib/ticketForm'
|
} from '../lib/ticketForm'
|
||||||
import { priorityFor } from '../lib/ticketStatus'
|
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.
|
// Neue Tickets gibt es aktuell nur fuer diese Arten; alte Tickets behalten ihre Art.
|
||||||
// Migration = ein Kunde, den wir von einem anderen Anbieter uebernehmen.
|
// Migration = ein Kunde, den wir von einem anderen Anbieter uebernehmen.
|
||||||
@@ -36,14 +35,18 @@ const emptyForm = () => ({
|
|||||||
details: ''
|
details: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function CreateTicketModal({ isOpen, onClose, onCreate, customers = [] }) {
|
// Stabile Vorgabe, damit die Vorschlagsliste nicht bei jedem Render neu entsteht
|
||||||
const { employees } = useEmployees()
|
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 [formData, setFormData] = useState(emptyForm)
|
||||||
const [recentTickets, setRecentTickets] = useState([])
|
const [recentTickets, setRecentTickets] = useState([])
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const errorRef = useRef(null)
|
||||||
|
useScrollIntoView(errorRef, error)
|
||||||
|
|
||||||
// Reset error when modal opens
|
// Reset error when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,26 +125,33 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay">
|
<Dialog
|
||||||
<span className="overlay-close" onClick={onClose}>
|
as="form"
|
||||||
<FaTimes />
|
wide
|
||||||
</span>
|
title="Neues Ticket"
|
||||||
<div className="overlay-content">
|
busy={loading}
|
||||||
<h2 className="mb-2">Neues Ticket</h2>
|
onClose={onClose}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<span className="grow" />
|
||||||
|
<Button variant="primary" type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Wird angelegt …' : 'Ticket anlegen'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
<div className="msg err" role="alert" ref={errorRef}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<div className="field-row">
|
||||||
<div className="row">
|
<Field label="Kunde" htmlFor="ticket-customer">
|
||||||
<div className="col col-6">
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Kunde</label>
|
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
id="ticket-customer"
|
||||||
|
className="select"
|
||||||
value={formData.customerId}
|
value={formData.customerId}
|
||||||
onChange={(e) => handleChange('customerId', e.target.value)}
|
onChange={(e) => handleChange('customerId', e.target.value)}
|
||||||
required
|
required
|
||||||
@@ -151,12 +161,12 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
<option key={c.$id} value={c.$id}>({c.code || ''}) {c.name || 'Unnamed'}</option>
|
<option key={c.$id} value={c.$id}>({c.code || ''}) {c.name || 'Unnamed'}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="form-group">
|
<Field label="Art" htmlFor="ticket-type">
|
||||||
<label className="form-label">Art</label>
|
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
id="ticket-type"
|
||||||
|
className="select"
|
||||||
value={formData.type}
|
value={formData.type}
|
||||||
onChange={(e) => handleChange('type', e.target.value)}
|
onChange={(e) => handleChange('type', e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -164,44 +174,22 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
<option key={type.value} value={type.value}>{type.label}</option>
|
<option key={type.value} value={type.value}>{type.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<Field label="Betreff" htmlFor="ticket-topic">
|
||||||
<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
|
<input
|
||||||
|
id="ticket-topic"
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="input"
|
||||||
placeholder="Worum geht es?"
|
placeholder="Worum geht es?"
|
||||||
value={formData.topic}
|
value={formData.topic}
|
||||||
onChange={(e) => handleChange('topic', e.target.value)}
|
onChange={(e) => handleChange('topic', e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="form-group">
|
<Field label="Angefragt von" htmlFor="ticket-requested-by">
|
||||||
<label className="form-label" htmlFor="ticket-requested-by">Angefragt von</label>
|
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
id="ticket-requested-by"
|
id="ticket-requested-by"
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
@@ -210,10 +198,10 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
getSuggestions={getSuggestions}
|
getSuggestions={getSuggestions}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="field-row">
|
||||||
<label className="form-label" htmlFor="ticket-start-date">Beginn</label>
|
<Field label="Beginn" htmlFor="ticket-start-date">
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
id="ticket-start-date"
|
id="ticket-start-date"
|
||||||
timeLabel="Uhrzeit Beginn"
|
timeLabel="Uhrzeit Beginn"
|
||||||
@@ -222,10 +210,9 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
onDateChange={handleStartDateChange}
|
onDateChange={handleStartDateChange}
|
||||||
onTimeChange={(value) => handleChange('startTime', value)}
|
onTimeChange={(value) => handleChange('startTime', value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="form-group">
|
<Field label="Deadline" htmlFor="ticket-end-date">
|
||||||
<label className="form-label" htmlFor="ticket-end-date">Deadline</label>
|
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
id="ticket-end-date"
|
id="ticket-end-date"
|
||||||
timeLabel="Uhrzeit Deadline"
|
timeLabel="Uhrzeit Deadline"
|
||||||
@@ -235,32 +222,35 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
onDateChange={(value) => handleChange('deadline', value)}
|
onDateChange={(value) => handleChange('deadline', value)}
|
||||||
onTimeChange={(value) => handleChange('endTime', value)}
|
onTimeChange={(value) => handleChange('endTime', value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<Field label="Beschreibung" htmlFor="ticket-details">
|
||||||
<label className="form-label">Beschreibung</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
className="form-control"
|
id="ticket-details"
|
||||||
|
className="textarea"
|
||||||
rows={5}
|
rows={5}
|
||||||
placeholder="Was soll gemacht werden?"
|
placeholder="Was soll gemacht werden?"
|
||||||
value={formData.details}
|
value={formData.details}
|
||||||
onChange={(e) => handleChange('details', e.target.value)}
|
onChange={(e) => handleChange('details', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="text-center mt-2">
|
<label className="check">
|
||||||
<button
|
<input
|
||||||
type="submit"
|
type="checkbox"
|
||||||
className="btn btn-dark"
|
checked={formData.urgent}
|
||||||
disabled={loading}
|
onChange={(e) => handleChange('urgent', e.target.checked)}
|
||||||
>
|
/>
|
||||||
{loading ? 'Wird angelegt …' : 'Ticket anlegen'}
|
Dringend
|
||||||
</button>
|
</label>
|
||||||
</div>
|
|
||||||
</form>
|
<Field
|
||||||
</div>
|
label="Zuweisung"
|
||||||
</div>
|
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 { useState, useEffect, useRef } from 'react'
|
||||||
import { FaTimes } from 'react-icons/fa'
|
import { useScrollIntoView } from '../hooks/useScrollIntoView'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { SERVICE_TYPES, todayIso, isoToGermanDate, timeToWoms, minutesBetween } from '../lib/ticketForm'
|
import { SERVICE_TYPES, todayIso, isoToGermanDate, timeToWoms, minutesBetween } from '../lib/ticketForm'
|
||||||
import { TICKET_STATUSES, statusLabel } from '../lib/ticketStatus'
|
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 { roundUpToStep, isValidStep } from '../lib/timeSteps'
|
||||||
import { validateSupporters } from '../lib/support'
|
import { validateSupporters } from '../lib/support'
|
||||||
import SupportersEditor from './SupportersEditor'
|
import SupportersEditor from './SupportersEditor'
|
||||||
@@ -35,7 +35,10 @@ const initialForm = (workorder, newStatus) => ({
|
|||||||
supporters: []
|
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 { user } = useAuth()
|
||||||
|
|
||||||
const isAcquisition = workorder?.type === 'Akquise'
|
const isAcquisition = workorder?.type === 'Akquise'
|
||||||
@@ -51,6 +54,8 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [autoCalculate, setAutoCalculate] = useState(true)
|
const [autoCalculate, setAutoCalculate] = useState(true)
|
||||||
|
const errorRef = useRef(null)
|
||||||
|
useScrollIntoView(errorRef, error)
|
||||||
|
|
||||||
// Reset form wenn Modal geöffnet wird
|
// Reset form wenn Modal geöffnet wird
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -148,26 +153,33 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
if (!isOpen || !workorder) return null
|
if (!isOpen || !workorder) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay">
|
<Dialog
|
||||||
<span className="overlay-close" onClick={onClose}>
|
as="form"
|
||||||
<FaTimes />
|
wide
|
||||||
</span>
|
title={`Neues Arbeitsblatt – Ticket ${workorder.woid}`}
|
||||||
<div className="overlay-content">
|
busy={loading}
|
||||||
<h2 className="mb-2">Neues Arbeitsblatt – Ticket {workorder.woid}</h2>
|
onClose={onClose}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<span className="grow" />
|
||||||
|
<Button variant="primary" type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Speichert …' : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
<div className="msg err" role="alert" ref={errorRef}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<div className="field-row">
|
||||||
<div className="row">
|
<Field label="Art" htmlFor="worksheet-service-type">
|
||||||
<div className="col col-6">
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Art</label>
|
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
id="worksheet-service-type"
|
||||||
|
className="select"
|
||||||
value={formData.serviceType}
|
value={formData.serviceType}
|
||||||
onChange={(e) => handleChange('serviceType', e.target.value)}
|
onChange={(e) => handleChange('serviceType', e.target.value)}
|
||||||
required
|
required
|
||||||
@@ -176,12 +188,16 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
<option key={type.value} value={type.value}>{type.label}</option>
|
<option key={type.value} value={type.value}>{type.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="form-group">
|
<Field
|
||||||
<label className="form-label">{isAcquisition ? 'Akquise-Ergebnis' : 'Neuer Status'}</label>
|
label={isAcquisition ? 'Akquise-Ergebnis' : 'Neuer Status'}
|
||||||
|
htmlFor="worksheet-new-status"
|
||||||
|
hint={isAcquisition ? '"Zugesagt" macht den Lead automatisch zum festen Kunden.' : undefined}
|
||||||
|
>
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
id="worksheet-new-status"
|
||||||
|
className="select"
|
||||||
value={formData.newStatus}
|
value={formData.newStatus}
|
||||||
onChange={(e) => handleChange('newStatus', e.target.value)}
|
onChange={(e) => handleChange('newStatus', e.target.value)}
|
||||||
required
|
required
|
||||||
@@ -190,15 +206,10 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
<option key={status.value} value={status.value}>{status.label}</option>
|
<option key={status.value} value={status.value}>{status.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{isAcquisition && (
|
</Field>
|
||||||
<small style={{ color: 'var(--text-2)', fontSize: '12px' }}>
|
|
||||||
"Zugesagt" macht den Lead automatisch zum festen Kunden.
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<label className="check">
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.isComment}
|
checked={formData.isComment}
|
||||||
@@ -206,11 +217,9 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
/>
|
/>
|
||||||
Nur Kommentar (keine Arbeitszeit)
|
Nur Kommentar (keine Arbeitszeit)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
|
|
||||||
{aboHoursAllowed && (
|
{aboHoursAllowed && (
|
||||||
<div className="form-group">
|
<label className="check" title="Zeit wird vom Stundenguthaben des Wartungs-Abos abgezogen und im Kundenportal angezeigt">
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }} title="Zeit wird vom Stundenguthaben des Wartungs-Abos abgezogen und im Kundenportal angezeigt">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.countsToPlan && !formData.isComment}
|
checked={formData.countsToPlan && !formData.isComment}
|
||||||
@@ -219,13 +228,10 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
/>
|
/>
|
||||||
Vom Abo-Stundenkonto abziehen
|
Vom Abo-Stundenkonto abziehen
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col col-6">
|
<div className="field-row">
|
||||||
<div className="form-group">
|
<Field label="Beginn" htmlFor="worksheet-start-date">
|
||||||
<label className="form-label" htmlFor="worksheet-start-date">Beginn</label>
|
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
id="worksheet-start-date"
|
id="worksheet-start-date"
|
||||||
timeLabel="Uhrzeit Beginn"
|
timeLabel="Uhrzeit Beginn"
|
||||||
@@ -235,10 +241,9 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
onDateChange={handleStartDateChange}
|
onDateChange={handleStartDateChange}
|
||||||
onTimeChange={(value) => handleChange('startTime', value)}
|
onTimeChange={(value) => handleChange('startTime', value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="form-group">
|
<Field label="Ende" htmlFor="worksheet-end-date">
|
||||||
<label className="form-label" htmlFor="worksheet-end-date">Ende</label>
|
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
id="worksheet-end-date"
|
id="worksheet-end-date"
|
||||||
timeLabel="Uhrzeit Ende"
|
timeLabel="Uhrzeit Ende"
|
||||||
@@ -249,24 +254,23 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
onDateChange={(value) => handleChange('endDate', value)}
|
onDateChange={(value) => handleChange('endDate', value)}
|
||||||
onTimeChange={(value) => handleChange('endTime', value)}
|
onTimeChange={(value) => handleChange('endTime', value)}
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<Field
|
||||||
<label className="form-label" htmlFor="worksheet-total">Arbeitszeit</label>
|
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
|
<DurationSelect
|
||||||
id="worksheet-total"
|
id="worksheet-total"
|
||||||
value={formData.isComment ? 0 : formData.totalTime}
|
value={formData.isComment ? 0 : formData.totalTime}
|
||||||
onChange={(value) => handleChange('totalTime', value)}
|
onChange={(value) => handleChange('totalTime', value)}
|
||||||
disabled={formData.isComment}
|
disabled={formData.isComment}
|
||||||
/>
|
/>
|
||||||
<small style={{ color: 'var(--text-2)', fontSize: '12px' }}>
|
</Field>
|
||||||
{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 && (
|
{!formData.isComment && (
|
||||||
<SupportersEditor
|
<SupportersEditor
|
||||||
@@ -274,33 +278,21 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
onChange={(list) => handleChange('supporters', list)}
|
onChange={(list) => handleChange('supporters', list)}
|
||||||
excludeUserId={user?.$id}
|
excludeUserId={user?.$id}
|
||||||
minutes={formData.totalTime}
|
minutes={formData.totalTime}
|
||||||
|
employeesState={employeesState}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<Field label="Was wurde gemacht?" htmlFor="worksheet-details">
|
||||||
<label className="form-label">Was wurde gemacht?</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
className="form-control"
|
id="worksheet-details"
|
||||||
|
className="textarea"
|
||||||
rows={5}
|
rows={5}
|
||||||
placeholder="Beschreibe die durchgeführten Arbeiten..."
|
placeholder="Beschreibe die durchgeführten Arbeiten..."
|
||||||
value={formData.details}
|
value={formData.details}
|
||||||
onChange={(e) => handleChange('details', e.target.value)}
|
onChange={(e) => handleChange('details', e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
</Dialog>
|
||||||
<div className="text-center mt-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-dark"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? 'Speichert …' : 'Speichern'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 { useEffect, useRef, useState } from 'react'
|
||||||
import { FaTimes } from 'react-icons/fa'
|
import { useScrollIntoView } from '../hooks/useScrollIntoView'
|
||||||
import { parseSupporters, supportMinutes } from '../lib/support'
|
import { parseSupporters, supportMinutes } from '../lib/support'
|
||||||
import { stepLabel } from '../lib/timeSteps'
|
import { stepLabel } from '../lib/timeSteps'
|
||||||
|
import { Button, Dialog, Field } from './ui'
|
||||||
|
|
||||||
/* "Ich habe unterstuetzt": die eigene Unterstuetzung bei einem fremden Arbeitsblatt
|
/* "Ich habe unterstuetzt": die eigene Unterstuetzung bei einem fremden Arbeitsblatt
|
||||||
eintragen, aendern oder entfernen. Andere Eintraege und die Zeiten bleiben unberuehrt. */
|
eintragen, aendern oder entfernen. Andere Eintraege und die Zeiten bleiben unberuehrt. */
|
||||||
@@ -10,18 +11,31 @@ export default function OwnSupportModal({ worksheet, currentUserId, onClose, onS
|
|||||||
const [task, setTask] = useState(existing?.task || '')
|
const [task, setTask] = useState(existing?.task || '')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
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) => {
|
const save = async (value) => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
try {
|
||||||
const result = await onSave(worksheet, value)
|
const result = await onSave(worksheet, value)
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
onClose()
|
onClose()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setError(result?.error || 'Speichern fehlgeschlagen')
|
setError(result?.error || 'Speichern fehlgeschlagen')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Speichern fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -33,48 +47,48 @@ export default function OwnSupportModal({ worksheet, currentUserId, onClose, onS
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay">
|
<Dialog
|
||||||
<span className="overlay-close" onClick={onClose}>
|
as="form"
|
||||||
<FaTimes />
|
title="Unterstützung eintragen"
|
||||||
</span>
|
busy={saving}
|
||||||
<div className="overlay-content" style={{ maxWidth: 480 }}>
|
onClose={onClose}
|
||||||
<h2 className="mb-2">Unterstützung eintragen</h2>
|
onSubmit={handleSubmit}
|
||||||
<p className="text-grey" style={{ fontSize: '14px', marginBottom: '16px' }}>
|
footer={
|
||||||
WSID {worksheet.wsid} · {worksheet.employeeName}
|
<>
|
||||||
<br />Dir werden {stepLabel(supportMinutes(worksheet.totalTime))} angerechnet.
|
{existing && (
|
||||||
</p>
|
<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 && (
|
{error && (
|
||||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
<div className="msg err" role="alert" ref={errorRef}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<p className="muted">
|
||||||
<div className="form-group">
|
WSID {worksheet.wsid} · {worksheet.employeeName}
|
||||||
<label className="form-label" htmlFor="own-support-task">Wobei?</label>
|
<br />Dir werden {stepLabel(supportMinutes(worksheet.totalTime))} angerechnet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Field label="Wobei?" htmlFor="own-support-task">
|
||||||
<input
|
<input
|
||||||
id="own-support-task"
|
id="own-support-task"
|
||||||
className="form-control"
|
ref={taskRef}
|
||||||
|
className="input"
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
required
|
required
|
||||||
value={task}
|
value={task}
|
||||||
onChange={(e) => setTask(e.target.value)}
|
onChange={(e) => setTask(e.target.value)}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="text-center mt-2 flex gap-2" style={{ justifyContent: 'center' }}>
|
</Dialog>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import { IconFolders } from '@tabler/icons-react'
|
import { Card } from './ui'
|
||||||
import { StatusPill } from './ui/StatusPill'
|
import { StatusPill } from './ui/StatusPill'
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
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
|
* Kontextbox im Ticket: "Weitere Tickets von <Kunde>" (klickbar).
|
||||||
* und alle anderen Tickets desselben Kunden (klickbar).
|
|
||||||
* Rendert nichts, wenn dem Ticket kein Projekt zugewiesen ist.
|
* Rendert nichts, wenn dem Ticket kein Projekt zugewiesen ist.
|
||||||
*/
|
*/
|
||||||
export default function ProjectContextPanel({ ticket }) {
|
export default function ProjectContextPanel({ ticket }) {
|
||||||
const { fetchByTicketId } = useWebsiteProjects()
|
const { fetchByTicketId } = useWebsiteProjects()
|
||||||
|
const location = useLocation()
|
||||||
const [context, setContext] = useState(null)
|
const [context, setContext] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -23,7 +25,6 @@ export default function ProjectContextPanel({ ticket }) {
|
|||||||
if (!cancelled) setContext(null)
|
if (!cancelled) setContext(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const projectName = assignedProjects[0].projectName || ''
|
|
||||||
|
|
||||||
let otherTickets = []
|
let otherTickets = []
|
||||||
if (ticket.customerId) {
|
if (ticket.customerId) {
|
||||||
@@ -34,7 +35,7 @@ export default function ProjectContextPanel({ ticket }) {
|
|||||||
])
|
])
|
||||||
otherTickets = res.documents.filter((t) => t.$id !== ticket.$id)
|
otherTickets = res.documents.filter((t) => t.$id !== ticket.$id)
|
||||||
}
|
}
|
||||||
if (!cancelled) setContext({ projectName, otherTickets })
|
if (!cancelled) setContext({ otherTickets })
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setContext(null)
|
if (!cancelled) setContext(null)
|
||||||
}
|
}
|
||||||
@@ -44,44 +45,27 @@ export default function ProjectContextPanel({ ticket }) {
|
|||||||
}, [ticket.$id, ticket.customerId, fetchByTicketId])
|
}, [ticket.$id, ticket.customerId, fetchByTicketId])
|
||||||
|
|
||||||
if (!context) return null
|
if (!context) return null
|
||||||
const { projectName, otherTickets } = context
|
const { otherTickets } = context
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ui-card pad" style={{ marginTop: 16 }}>
|
<Card title={ticket.customerName ? `Weitere Tickets von ${ticket.customerName}` : 'Weitere Tickets'}>
|
||||||
<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>
|
|
||||||
|
|
||||||
{!ticket.customerId ? (
|
{!ticket.customerId ? (
|
||||||
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
|
<p className="muted">Kein Kunde zugeordnet — andere Tickets koennen nicht automatisch verknuepft werden.</p>
|
||||||
Kein Kunde zugeordnet — andere Tickets koennen nicht automatisch verknuepft werden.
|
|
||||||
</p>
|
|
||||||
) : !otherTickets.length ? (
|
) : !otherTickets.length ? (
|
||||||
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
|
<p className="muted">Keine weiteren Tickets zu diesem Kunden/Projekt.</p>
|
||||||
Keine weiteren Tickets zu diesem Kunden/Projekt.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div>
|
||||||
{otherTickets.map((t) => (
|
{otherTickets.map((t) => (
|
||||||
<Link
|
<Link key={t.$id} className="list-row" to={ticketPath(ticketRef(t))} state={location.state}>
|
||||||
key={t.$id}
|
<span className="cell-title list-row-text">
|
||||||
to={`/tickets?woid=${t.woid}`}
|
<span className="id">{ticketNumber(t)}</span>
|
||||||
style={{
|
<span>{t.topic || t.title || '-'}</span>
|
||||||
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 || '-'}
|
|
||||||
</span>
|
</span>
|
||||||
<StatusPill status={t.status} />
|
<StatusPill status={t.status} />
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 { useState } from 'react'
|
||||||
import { FaTimes } from 'react-icons/fa'
|
|
||||||
import { useEmployees } from '../hooks/useEmployees'
|
|
||||||
import { supportMinutes } from '../lib/support'
|
import { supportMinutes } from '../lib/support'
|
||||||
import { stepLabel } from '../lib/timeSteps'
|
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
|
/* Haken "Unterstuetzung" mit Zeilen Mitarbeiter + "Wobei?". Nur fuer die Person, die das
|
||||||
Arbeitsblatt angelegt hat (sie selbst steht nicht in der Auswahl). Die Mitarbeiterliste
|
Arbeitsblatt angelegt hat (sie selbst steht nicht in der Auswahl). Die Mitarbeiterliste
|
||||||
laedt erst hier, damit nicht jede Ticket-Karte sie abruft. */
|
kommt von der Seite (employeesState = Werte von useEmployees, dort einmal geladen),
|
||||||
export default function SupportersEditor({ value, onChange, excludeUserId, minutes }) {
|
der Editor ruft den Hook nicht selbst auf. */
|
||||||
const { employees, loading, error } = useEmployees()
|
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 [enabled, setEnabled] = useState(value.length > 0)
|
||||||
const perPerson = stepLabel(supportMinutes(minutes))
|
const perPerson = stepLabel(supportMinutes(minutes))
|
||||||
const choices = employees.filter((e) => e.userId && e.userId !== excludeUserId)
|
const choices = employees.filter((e) => e.userId && e.userId !== excludeUserId)
|
||||||
@@ -29,20 +31,20 @@ export default function SupportersEditor({ value, onChange, excludeUserId, minut
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-group supporters-editor">
|
<div className="field supporters-editor">
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<label className="check">
|
||||||
<input type="checkbox" checked={enabled} onChange={(e) => toggle(e.target.checked)} />
|
<input type="checkbox" checked={enabled} onChange={(e) => toggle(e.target.checked)} />
|
||||||
Unterstützung
|
Unterstützung
|
||||||
</label>
|
</label>
|
||||||
{enabled && (
|
{enabled && (
|
||||||
<div style={{ marginTop: 8 }}>
|
<>
|
||||||
{!loading && (error || !choices.length) && (
|
{!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) => (
|
{value.map((row, index) => (
|
||||||
<div key={index} className="flex gap-2" style={{ marginBottom: 8, alignItems: 'center' }}>
|
<div key={index} className="supporter-row">
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
className="select"
|
||||||
aria-label="Mitarbeiter"
|
aria-label="Mitarbeiter"
|
||||||
value={row.employeeId}
|
value={row.employeeId}
|
||||||
onChange={(e) => pick(index, e.target.value)}
|
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>)}
|
.map((e) => <option key={e.userId} value={e.userId}>{e.displayName}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
className="form-control"
|
className="input"
|
||||||
aria-label="Wobei?"
|
aria-label="Wobei?"
|
||||||
placeholder="Wobei?"
|
placeholder="Wobei?"
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
value={row.task}
|
value={row.task}
|
||||||
onChange={(e) => update(index, { task: e.target.value })}
|
onChange={(e) => update(index, { task: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<span className="text-grey" style={{ whiteSpace: 'nowrap', fontSize: 13 }}>= {perPerson}</span>
|
<div className="sup-meta">
|
||||||
<button type="button" className="btn btn-sm" aria-label="Zeile entfernen" onClick={() => remove(index)}>
|
<span>= {perPerson}</span>
|
||||||
<FaTimes />
|
<button type="button" className="link" aria-label="Zeile entfernen" onClick={() => remove(index)}>
|
||||||
|
Entfernen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
<button type="button" className="btn btn-sm" onClick={() => onChange([...value, { employeeId: '', name: '', task: '' }])}>
|
<div>
|
||||||
|
<button type="button" className="link" onClick={() => onChange([...value, { employeeId: '', name: '', task: '' }])}>
|
||||||
+ weitere Person
|
+ weitere Person
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</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'
|
import { isUrgent, priorityFor } from '../lib/ticketStatus'
|
||||||
|
|
||||||
// Ein Klick markiert ein Ticket als dringend oder wieder als normal
|
// Ein Klick markiert ein Ticket als dringend oder wieder als normal; onChange bekommt schon die Prioritaetszahl
|
||||||
export default function UrgentToggle({ value, onChange }) {
|
export default function UrgentToggle({ value, onChange, disabled }) {
|
||||||
const urgent = isUrgent(value)
|
const urgent = isUrgent(value)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -10,7 +10,9 @@ export default function UrgentToggle({ value, onChange }) {
|
|||||||
aria-pressed={urgent}
|
aria-pressed={urgent}
|
||||||
title={urgent ? 'Nicht mehr dringend' : 'Als dringend markieren'}
|
title={urgent ? 'Nicht mehr dringend' : 'Als dringend markieren'}
|
||||||
onClick={() => onChange(priorityFor(!urgent))}
|
onClick={() => onChange(priorityFor(!urgent))}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
<span className="sw" aria-hidden="true" />
|
||||||
Dringend
|
Dringend
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,342 +1,115 @@
|
|||||||
import { useState } from 'react'
|
import { EmptyState } from './ui'
|
||||||
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp, FaPen, FaCheck, FaHandsHelping } from 'react-icons/fa'
|
import { needsTimeEntry, isGitWorksheet } from '../lib/ticketForm'
|
||||||
import { serviceTypeLabel, needsTimeEntry, worksheetTone } from '../lib/ticketForm'
|
import { parseSupporters } from '../lib/support'
|
||||||
import { statusLabel } from '../lib/ticketStatus'
|
import {
|
||||||
import { parseSupporters, supportMinutes, firstName } from '../lib/support'
|
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
|
// Arbeitsblaetter, neueste zuerst wie geliefert: links Datum und Beginn, in der Mitte Person, Art, Text und Aktionen, rechts die Zeit
|
||||||
// grau (Schrift --text auf --bg-subtle); P2 baut die Liste um.
|
export default function WorksheetList({
|
||||||
const TONES = {
|
worksheets,
|
||||||
'git-open': { border: 'var(--orange-fg)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' },
|
loading,
|
||||||
'git-done': { border: 'var(--green-fg)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' },
|
onTogglePlan,
|
||||||
comment: { border: 'var(--blue-fg)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' },
|
aboHoursAllowed = true,
|
||||||
work: { border: 'var(--text-2)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' },
|
canEditTimes,
|
||||||
}
|
onEditTimes,
|
||||||
|
canAddSupport,
|
||||||
// Hinweise im Kopf: graue Badge-Toene
|
onOwnSupport,
|
||||||
const headerBadgeStyle = { background: 'var(--grey-bg)', color: 'var(--grey-fg)', marginLeft: '8px' }
|
currentUserId,
|
||||||
|
}) {
|
||||||
const headerButtonStyle = {
|
if (loading) return <div className="loading">Wird geladen …</div>
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!worksheets || worksheets.length === 0) {
|
if (!worksheets || worksheets.length === 0) {
|
||||||
|
return <EmptyState title="Noch keine Arbeitsblätter." hint="Leg das erste mit „Arbeitsblatt hinzufügen“ an." />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="alert alert-info" role="alert">
|
<div className="ws-list">
|
||||||
<FaComment className="me-2" />
|
{worksheets.map((ws) => {
|
||||||
Noch keine Arbeitsblätter. Leg das erste mit „Arbeitsblatt hinzufügen“ an.
|
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>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<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>
|
<div>
|
||||||
<strong className="fs-6">WSID {ws.wsid}</strong>
|
<div className="ws-head">
|
||||||
{ws.isComment && !isGit && (
|
<span className="who">{ws.employeeName}</span>
|
||||||
<span className="badge ms-2" style={headerBadgeStyle}>
|
{ws.employeeShort && <span className="faint xs">{ws.employeeShort}</span>}
|
||||||
<FaComment className="me-1" /> Kommentar
|
{kind && <span className="muted">{kind}</span>}
|
||||||
</span>
|
<span className="faint xs num">WSID {ws.wsid}</span>
|
||||||
)}
|
{badge && <span className={`badge ${badge.tone}`}>{badge.text}</span>}
|
||||||
{tone === 'git-open' && (
|
{showAbo ? (
|
||||||
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-sm"
|
className={ws.countsToPlan ? 'abo-toggle on' : 'abo-toggle'}
|
||||||
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'}
|
title={ws.countsToPlan ? 'Nicht mehr vom Abo-Stundenkonto abziehen' : 'Vom Abo-Stundenkonto des Kunden abziehen'}
|
||||||
style={{
|
onClick={() => onTogglePlan(ws)}
|
||||||
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'}
|
{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>
|
</button>
|
||||||
)}
|
)}
|
||||||
<small style={{ opacity: 0.9 }}>
|
{canSupport && (
|
||||||
{formatDateTime(ws.startDate, ws.startTime) || formatDateTime(ws.endDate, ws.endTime)}
|
<button
|
||||||
</small>
|
type="button"
|
||||||
</div>
|
className="link"
|
||||||
</div>
|
title="Eintragen, dass du bei dieser Arbeit unterstützt hast"
|
||||||
|
onClick={() => onOwnSupport(ws)}
|
||||||
{/* Body - Nur wenn expanded */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div
|
|
||||||
className="card-body p-4"
|
|
||||||
style={{
|
|
||||||
animation: 'slideDown 0.3s ease-out'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Mitarbeiter & Zeit */}
|
{ownSupport ? 'Meine Unterstützung bearbeiten' : 'Ich habe unterstützt'}
|
||||||
<div className="row mb-3">
|
</button>
|
||||||
<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>
|
|
||||||
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Unterstuetzung: je Person die Haelfte der Arbeitszeit, aufgerundet auf 15 Minuten */}
|
<div className="ws-time">
|
||||||
{supporters.length > 0 && (
|
{time && <div className={time === '–' ? 't faint' : 't'}>{time}</div>}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,25 +1,17 @@
|
|||||||
import { needsTimeEntry } from '../lib/ticketForm'
|
import { Stat } from './ui'
|
||||||
import { worksheetMinutes } from '../lib/support'
|
import { worksheetSums } from '../lib/worksheetView'
|
||||||
|
import { stepLabel } from '../lib/timeSteps'
|
||||||
|
|
||||||
const formatTime = (minutes) => {
|
// Kennzahlen ueber der Arbeitsblatt-Liste: Arbeitszeit (inkl. Unterstuetzung), davon Abo-Stunden, offene Git-Zeiten
|
||||||
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
|
|
||||||
export default function WorksheetSummary({ worksheets }) {
|
export default function WorksheetSummary({ worksheets }) {
|
||||||
if (!worksheets || worksheets.length === 0) return null
|
if (!worksheets || worksheets.length === 0) return null
|
||||||
const minutes = worksheets
|
const { totalMinutes, aboMinutes, openCount } = worksheetSums(worksheets)
|
||||||
.filter((ws) => !ws.isComment)
|
|
||||||
.reduce((sum, ws) => sum + worksheetMinutes(ws), 0)
|
|
||||||
const open = worksheets.filter(needsTimeEntry).length
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="worksheet-summary">
|
<div className="stats">
|
||||||
<span>Arbeitszeit gesamt <strong>{formatTime(minutes)}</strong></span>
|
<Stat label="Arbeitszeit gesamt" value={stepLabel(totalMinutes)} />
|
||||||
<span>{worksheets.length} {worksheets.length === 1 ? 'Arbeitsblatt' : 'Arbeitsblätter'}</span>
|
<Stat label="Davon Abo-Stunden" value={stepLabel(aboMinutes)} />
|
||||||
{open > 0 && <span className="open">{open} {open === 1 ? 'Zeit offen' : 'Zeiten offen'}</span>}
|
<Stat label="Zeiten offen" value={openCount} tone={openCount > 0 ? 'warn' : ''} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { FaTimes } from 'react-icons/fa'
|
import { useScrollIntoView } from '../hooks/useScrollIntoView'
|
||||||
import { isoToGermanDate, timeToWoms, germanDateToIso, womsTimeToInput, minutesBetween, isGitWorksheet, needsTimeEntry } from '../lib/ticketForm'
|
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 { roundUpToStep, isValidStep } from '../lib/timeSteps'
|
||||||
import { parseSupporters, validateSupporters } from '../lib/support'
|
import { parseSupporters, validateSupporters } from '../lib/support'
|
||||||
import SupportersEditor from './SupportersEditor'
|
import SupportersEditor from './SupportersEditor'
|
||||||
@@ -14,11 +14,14 @@ const formFromWorksheet = (worksheet) => ({
|
|||||||
endTime: womsTimeToInput(worksheet.endTime)
|
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.
|
* Beginn, Ende und Arbeitszeit eines Arbeitsblatts nachtraeglich aendern.
|
||||||
* Wer das darf, entscheidet canEditWorksheetTimes (lib/ticketForm).
|
* 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 isGit = isGitWorksheet(worksheet)
|
||||||
const [formData, setFormData] = useState(() => formFromWorksheet(worksheet))
|
const [formData, setFormData] = useState(() => formFromWorksheet(worksheet))
|
||||||
// null = die Arbeitszeit folgt Beginn und Ende (aufgerundet auf 15 Minuten). Passte sie
|
// 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 [supporters, setSupporters] = useState(() => parseSupporters(worksheet.supporters))
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const errorRef = useRef(null)
|
||||||
|
useScrollIntoView(errorRef, error)
|
||||||
|
|
||||||
const calculatedMinutes = minutesBetween(formData.startTime, formData.endTime)
|
const calculatedMinutes = minutesBetween(formData.startTime, formData.endTime)
|
||||||
const calculatedTotal = calculatedMinutes === null ? null : roundUpToStep(calculatedMinutes)
|
const calculatedTotal = calculatedMinutes === null ? null : roundUpToStep(calculatedMinutes)
|
||||||
@@ -86,27 +91,33 @@ export default function WorksheetTimesModal({ worksheet, currentUserId, onClose,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay">
|
<Dialog
|
||||||
<span className="overlay-close" onClick={onClose}>
|
as="form"
|
||||||
<FaTimes />
|
title={needsTimeEntry(worksheet) ? 'Zeit nachtragen' : 'Zeiten bearbeiten'}
|
||||||
</span>
|
busy={saving}
|
||||||
<div className="overlay-content" style={{ maxWidth: 560 }}>
|
onClose={onClose}
|
||||||
{/* Kurzer Titel: auf dem Handy laeuft ein langer unter das Schliessen-X */}
|
onSubmit={handleSubmit}
|
||||||
<h2 className="mb-2">{needsTimeEntry(worksheet) ? 'Zeit nachtragen' : 'Zeiten bearbeiten'}</h2>
|
footer={
|
||||||
<p className="text-grey" style={{ fontSize: '14px', marginBottom: '16px' }}>
|
<>
|
||||||
WSID {worksheet.wsid}
|
<span className="grow" />
|
||||||
{isGit && <><br />Das Ende ist der Zeitpunkt des Git-Pushs. Trag ein, wann die Arbeit begonnen hat.</>}
|
<Button variant="primary" type="submit" disabled={saving}>
|
||||||
</p>
|
{saving ? 'Speichert …' : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
<div className="msg err" role="alert" ref={errorRef}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<p className="muted">
|
||||||
<div className="form-group">
|
WSID {worksheet.wsid}
|
||||||
<label className="form-label" htmlFor="worksheet-times-start">Beginn</label>
|
{isGit && <><br />Das Ende ist der Zeitpunkt des Git-Pushs. Trag ein, wann die Arbeit begonnen hat.</>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Field label="Beginn" htmlFor="worksheet-times-start">
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
id="worksheet-times-start"
|
id="worksheet-times-start"
|
||||||
timeLabel="Uhrzeit Beginn"
|
timeLabel="Uhrzeit Beginn"
|
||||||
@@ -117,10 +128,9 @@ export default function WorksheetTimesModal({ worksheet, currentUserId, onClose,
|
|||||||
onDateChange={handleStartDateChange}
|
onDateChange={handleStartDateChange}
|
||||||
onTimeChange={(value) => handleChange('startTime', value)}
|
onTimeChange={(value) => handleChange('startTime', value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="form-group">
|
<Field label="Ende" htmlFor="worksheet-times-end">
|
||||||
<label className="form-label" htmlFor="worksheet-times-end">Ende</label>
|
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
id="worksheet-times-end"
|
id="worksheet-times-end"
|
||||||
timeLabel="Uhrzeit Ende"
|
timeLabel="Uhrzeit Ende"
|
||||||
@@ -131,36 +141,29 @@ export default function WorksheetTimesModal({ worksheet, currentUserId, onClose,
|
|||||||
onDateChange={(value) => handleChange('endDate', value)}
|
onDateChange={(value) => handleChange('endDate', value)}
|
||||||
onTimeChange={(value) => handleChange('endTime', value)}
|
onTimeChange={(value) => handleChange('endTime', value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="form-group">
|
<Field
|
||||||
<label className="form-label" htmlFor="worksheet-times-total">Arbeitszeit</label>
|
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
|
<DurationSelect
|
||||||
id="worksheet-times-total"
|
id="worksheet-times-total"
|
||||||
value={totalTime}
|
value={totalTime}
|
||||||
onChange={(value) => setManualTotal(value)}
|
onChange={(value) => setManualTotal(value)}
|
||||||
/>
|
/>
|
||||||
<small style={{ color: 'var(--text-2)', fontSize: '12px' }}>
|
</Field>
|
||||||
{manualTotal === null && calculatedTotal !== null
|
|
||||||
? '✓ Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
|
|
||||||
: 'Von Hand gewählt'}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SupportersEditor
|
<SupportersEditor
|
||||||
value={supporters}
|
value={supporters}
|
||||||
onChange={setSupporters}
|
onChange={setSupporters}
|
||||||
excludeUserId={currentUserId}
|
excludeUserId={currentUserId}
|
||||||
minutes={totalTime}
|
minutes={totalTime}
|
||||||
|
employeesState={employeesState}
|
||||||
/>
|
/>
|
||||||
|
</Dialog>
|
||||||
<div className="text-center mt-2">
|
|
||||||
<button type="submit" className="btn btn-dark" disabled={saving}>
|
|
||||||
{saving ? 'Speichert...' : 'Speichern'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,14 @@ import { useEffect, useId, useRef } from 'react'
|
|||||||
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
||||||
import { shouldCloseOnKey } from '../../lib/dialogKeys'
|
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).
|
* 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).
|
* 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
|
* (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.
|
* 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).
|
* 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 }
|
latest.current = { busy, onClose }
|
||||||
})
|
})
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
openDialogs.push(latest)
|
||||||
const onKeyDown = (e) => {
|
const onKeyDown = (e) => {
|
||||||
|
if (openDialogs[openDialogs.length - 1] !== latest) return
|
||||||
const { busy: isBusy, onClose: close } = latest.current
|
const { busy: isBusy, onClose: close } = latest.current
|
||||||
if (shouldCloseOnKey(e, isBusy)) close()
|
if (shouldCloseOnKey(e, isBusy)) close()
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKeyDown)
|
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 (
|
return (
|
||||||
@@ -44,7 +54,7 @@ export default function Dialog({ title, onClose, busy = false, wide = false, as
|
|||||||
>
|
>
|
||||||
<div className="dialog-head">
|
<div className="dialog-head">
|
||||||
<h3 id={titleId}>{title}</h3>
|
<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>
|
</button>
|
||||||
</div>
|
</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"])'
|
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
|
* 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).
|
* Umschalt+Tab bleiben im Dialog (sonst landet man hinter dem Overlay in der Seite).
|
||||||
*/
|
*/
|
||||||
export function useDialogFocus(dialogRef) {
|
export function useDialogFocus(dialogRef) {
|
||||||
|
const openerRef = useRef(undefined)
|
||||||
|
if (openerRef.current === undefined) openerRef.current = document.activeElement
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const opener = document.activeElement
|
|
||||||
dialogRef.current?.focus()
|
dialogRef.current?.focus()
|
||||||
return () => {
|
return () => {
|
||||||
|
const opener = openerRef.current
|
||||||
if (opener && opener.isConnected && typeof opener.focus === 'function') opener.focus()
|
if (opener && opener.isConnected && typeof opener.focus === 'function') opener.focus()
|
||||||
}
|
}
|
||||||
}, [dialogRef])
|
}, [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 { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
|
||||||
import { ticketMatches, NORMAL_PRIORITY } from '../lib/ticketStatus'
|
import { ticketMatches, NORMAL_PRIORITY } from '../lib/ticketStatus'
|
||||||
|
import { createRequestGuard } from '../lib/requestGuard'
|
||||||
|
|
||||||
function buildFiltersKey(filters = {}) {
|
function buildFiltersKey(filters = {}) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
@@ -20,6 +21,8 @@ export function useWorkorders(filters = {}) {
|
|||||||
const [workorders, setWorkorders] = useState([])
|
const [workorders, setWorkorders] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
// Nur die juengste Anfrage darf das Ergebnis uebernehmen
|
||||||
|
const guard = useRef(createRequestGuard())
|
||||||
const filtersKey = useMemo(() => buildFiltersKey(filters), [
|
const filtersKey = useMemo(() => buildFiltersKey(filters), [
|
||||||
filters.limit,
|
filters.limit,
|
||||||
filters.customerId,
|
filters.customerId,
|
||||||
@@ -34,6 +37,7 @@ export function useWorkorders(filters = {}) {
|
|||||||
|
|
||||||
const fetchWorkorders = useCallback(async () => {
|
const fetchWorkorders = useCallback(async () => {
|
||||||
const activeFilters = JSON.parse(filtersKey)
|
const activeFilters = JSON.parse(filtersKey)
|
||||||
|
const requestId = guard.current.next()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -74,7 +78,7 @@ export function useWorkorders(filters = {}) {
|
|||||||
queries.push(Query.equal('assignedTo', activeFilters.assignedTo))
|
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) {
|
if (activeFilters.woid) {
|
||||||
queries.push(Query.equal('woid', [String(activeFilters.woid)]))
|
queries.push(Query.equal('woid', [String(activeFilters.woid)]))
|
||||||
}
|
}
|
||||||
@@ -92,6 +96,7 @@ export function useWorkorders(filters = {}) {
|
|||||||
COLLECTIONS.WORKORDERS,
|
COLLECTIONS.WORKORDERS,
|
||||||
queries
|
queries
|
||||||
)
|
)
|
||||||
|
if (!guard.current.isCurrent(requestId)) return
|
||||||
|
|
||||||
setWorkorders(
|
setWorkorders(
|
||||||
activeFilters.search
|
activeFilters.search
|
||||||
@@ -100,6 +105,7 @@ export function useWorkorders(filters = {}) {
|
|||||||
)
|
)
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!guard.current.isCurrent(requestId)) return
|
||||||
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
|
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
|
||||||
|
|
||||||
// Bessere Fehlermeldungen
|
// Bessere Fehlermeldungen
|
||||||
@@ -112,7 +118,7 @@ export function useWorkorders(filters = {}) {
|
|||||||
setError(errorMessage)
|
setError(errorMessage)
|
||||||
console.error('Error fetching workorders:', err)
|
console.error('Error fetching workorders:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (guard.current.isCurrent(requestId)) setLoading(false)
|
||||||
}
|
}
|
||||||
}, [filtersKey])
|
}, [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
|
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
|
// Filter: jede Gruppe fasst die gespeicherten Werte zusammen, auch die alten
|
||||||
export const STATUS_GROUPS = [
|
export const STATUS_GROUPS = [
|
||||||
{ id: 'open', label: 'Offen', values: ['Open'] },
|
{ 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)
|
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.
|
// Statt fuenf Prioritaeten gibt es nur noch "dringend" oder nicht.
|
||||||
// Frueher gespeicherte Stufen: Hoch (3) und Kritisch (4) gelten als dringend.
|
// Frueher gespeicherte Stufen: Hoch (3) und Kritisch (4) gelten als dringend.
|
||||||
export const NORMAL_PRIORITY = 2
|
export const NORMAL_PRIORITY = 2
|
||||||
@@ -106,7 +100,7 @@ export const TYPE_FILTERS = [
|
|||||||
{ id: 'Migration', label: 'Migration', types: ['Migration'] },
|
{ id: 'Migration', label: 'Migration', types: ['Migration'] },
|
||||||
{ id: 'Project', label: 'Projekt', types: ['Project'] },
|
{ id: 'Project', label: 'Projekt', types: ['Project'] },
|
||||||
{ id: 'Akquise', label: 'Akquise', types: ['Akquise'] },
|
{ 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
|
// 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 { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } from '../lib/assignFlow'
|
||||||
import { suggestSubdomain, slugify } from '../lib/subdomain'
|
import { suggestSubdomain, slugify } from '../lib/subdomain'
|
||||||
import { isArchived } from '../lib/leads'
|
import { isArchived } from '../lib/leads'
|
||||||
|
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
|
||||||
import { PageHeader, Card, Button, Field } from '../components/ui'
|
import { PageHeader, Card, Button, Field } from '../components/ui'
|
||||||
import { CreateCustomerForm } from './CustomersPage'
|
import { CreateCustomerForm } from './CustomersPage'
|
||||||
|
|
||||||
@@ -153,7 +154,7 @@ export default function AssignProjectPage() {
|
|||||||
if (result.ticketId) {
|
if (result.ticketId) {
|
||||||
try {
|
try {
|
||||||
const t = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, result.ticketId)
|
const t = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, result.ticketId)
|
||||||
path = `/tickets?woid=${encodeURIComponent(t.woid)}`
|
path = ticketPath(ticketRef(t))
|
||||||
} catch {
|
} catch {
|
||||||
path = '/tickets'
|
path = '/tickets'
|
||||||
}
|
}
|
||||||
@@ -402,5 +403,5 @@ function TicketLink({ ticketId }) {
|
|||||||
databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticketId)
|
databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticketId)
|
||||||
.then((t) => setWoid(t.woid)).catch(() => setWoid(null))
|
.then((t) => setWoid(t.woid)).catch(() => setWoid(null))
|
||||||
}, [ticketId])
|
}, [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 { useWorkorders } from '../hooks/useWorkorders'
|
||||||
import { useEmployees } from '../hooks/useEmployees'
|
import { useEmployees } from '../hooks/useEmployees'
|
||||||
import { groupAppointmentsByDay, countInRange, dayKey } from '../lib/planboard'
|
import { groupAppointmentsByDay, countInRange, dayKey } from '../lib/planboard'
|
||||||
|
import { ticketPath } from '../lib/ticketRoutes'
|
||||||
|
|
||||||
/* Termine haengen an den Tickets (startDate/startTime/endTime/assignedTo);
|
/* Termine haengen an den Tickets (startDate/startTime/endTime/assignedTo);
|
||||||
die Aufbereitung steckt in src/lib/planboard.js. */
|
die Aufbereitung steckt in src/lib/planboard.js. */
|
||||||
@@ -79,7 +80,7 @@ export default function PlanboardPage() {
|
|||||||
key={entry.id}
|
key={entry.id}
|
||||||
type="button"
|
type="button"
|
||||||
title={`Ticket #${entry.woid} öffnen`}
|
title={`Ticket #${entry.woid} öffnen`}
|
||||||
onClick={() => navigate(`/tickets?woid=${encodeURIComponent(entry.woid)}`)}
|
onClick={() => navigate(ticketPath(entry.woid || entry.id))}
|
||||||
style={{
|
style={{
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FaSpinner } from 'react-icons/fa6'
|
|||||||
import { dotColor } from '../lib/previewEnv'
|
import { dotColor } from '../lib/previewEnv'
|
||||||
import { fetchServices } from '../lib/projectKindsApi'
|
import { fetchServices } from '../lib/projectKindsApi'
|
||||||
import { servicesCards } from '../lib/servicesView'
|
import { servicesCards } from '../lib/servicesView'
|
||||||
|
import { ticketPath } from '../lib/ticketRoutes'
|
||||||
import { PageHeader, Card, Button, Badge } from '../components/ui'
|
import { PageHeader, Card, Button, Badge } from '../components/ui'
|
||||||
|
|
||||||
export default function ServicesPage() {
|
export default function ServicesPage() {
|
||||||
@@ -69,7 +70,7 @@ export default function ServicesPage() {
|
|||||||
{[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'}
|
{[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'}
|
||||||
</div>
|
</div>
|
||||||
</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' && (
|
{c.key === 'missing' && (
|
||||||
<Button variant="primary" onClick={() => navigate(`/projects/zuordnen/${p.id}`)}>Zuordnen</Button>
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+225
-121
@@ -1,78 +1,208 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { FaAngleDown, FaSpinner, FaPlus, FaSliders } from 'react-icons/fa6'
|
import { PageHeader, Tabs, Button, EmptyState, StatusPill, UrgentPill } from '../components/ui'
|
||||||
import { useWorkorders } from '../hooks/useWorkorders'
|
|
||||||
import { useCustomers } from '../hooks/useCustomers'
|
|
||||||
import TicketCard from '../components/TicketCard'
|
|
||||||
import CreateTicketModal from '../components/CreateTicketModal'
|
import CreateTicketModal from '../components/CreateTicketModal'
|
||||||
import { PageHeader, Card, Button, EmptyState } from '../components/ui'
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
import { STATUS_GROUPS, DEFAULT_STATUS_GROUPS, statusValuesFor, TYPE_FILTERS } from '../lib/ticketStatus'
|
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)
|
// Pause nach dem letzten Tastendruck, bis der Suchtext in die Adresse übernommen wird
|
||||||
const linkButton = { fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 }
|
const SEARCH_DELAY_MS = 300
|
||||||
|
|
||||||
// Aus Suchtext, Art, Status-Gruppen und "Nur dringende" die Abfrage fuer useWorkorders bauen.
|
// Altlinks aus den Leads (Liste mit Suchteil woid=123) führen zur Ticketseite; dann läuft keine Abfrage der Liste
|
||||||
// Sind alle oder keine Status-Gruppen gewaehlt, wird nicht nach Status gefiltert.
|
export default function TicketsPage() {
|
||||||
function toFilters({ search, typeId, groups, urgentOnly }) {
|
const [searchParams] = useSearchParams()
|
||||||
const typeFilter = TYPE_FILTERS.find((filter) => filter.id === typeId) || TYPE_FILTERS[0]
|
const target = woidRedirectTarget(searchParams)
|
||||||
const allGroups = groups.length === 0 || groups.length === ALL_GROUPS.length
|
if (target) return <Navigate replace to={target} />
|
||||||
return {
|
return <TicketList />
|
||||||
status: allGroups ? [] : statusValuesFor(groups),
|
|
||||||
type: typeFilter.types || [],
|
|
||||||
excludeType: typeFilter.excludeType,
|
|
||||||
priority: urgentOnly ? [3, 4] : [],
|
|
||||||
search: search.trim() || undefined,
|
|
||||||
woid: undefined,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
function TicketList() {
|
||||||
const [limit, setLimit] = useState(10)
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [filters, setFilters] = useState({ ...toFilters(DEFAULT_SELECTION), limit: 10 })
|
const location = useLocation()
|
||||||
const [searchText, setSearchText] = useState('')
|
const navigate = useNavigate()
|
||||||
const [typeId, setTypeId] = useState(DEFAULT_SELECTION.typeId)
|
const state = useMemo(() => parseListParams(searchParams), [searchParams])
|
||||||
const [groups, setGroups] = useState(DEFAULT_SELECTION.groups)
|
const filters = useMemo(() => listFilters(state), [state])
|
||||||
const [urgentOnly, setUrgentOnly] = useState(false)
|
// Wohin "Zurück" auf der Ticketseite führt: dieselbe Liste mit denselben Filtern
|
||||||
|
const listSearch = location.search
|
||||||
|
|
||||||
const toggleGroup = (id) => {
|
// Jede Änderung geht in die Adresse und ersetzt den Eintrag (der Zurück-Knopf springt nicht durch Filterschritte)
|
||||||
setGroups((prev) => (prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id]))
|
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)
|
// Suche: lokaler Text, Übernahme in die Adresse nach Tipp-Pause oder mit Enter.
|
||||||
const { customers } = useCustomers()
|
// 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 commitSearch = useCallback((text) => {
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
const next = text.trim()
|
||||||
const [searchParams] = useSearchParams()
|
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(() => {
|
useEffect(() => {
|
||||||
const w = searchParams.get('woid')
|
if (state.search === appliedRef.current) return
|
||||||
if (!w) return
|
appliedRef.current = state.search
|
||||||
setSearchText(w)
|
setSearchText(state.search)
|
||||||
setTypeId('all')
|
}, [state.search])
|
||||||
setGroups(ALL_GROUPS)
|
|
||||||
setFilters((prev) => ({ ...prev, status: [], type: [], excludeType: undefined, priority: [], search: undefined, woid: w }))
|
|
||||||
}, [searchParams])
|
|
||||||
|
|
||||||
const applyFilters = (e) => {
|
useEffect(() => {
|
||||||
e?.preventDefault()
|
if (searchText.trim() === appliedRef.current) return
|
||||||
setFilters((prev) => ({ ...prev, ...toFilters({ search: searchText, typeId, groups, urgentOnly }), limit }))
|
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 handleCreate = async (data) => {
|
||||||
const result = await createWorkorder(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
|
return result
|
||||||
}
|
}
|
||||||
const loadMore = () => {
|
|
||||||
const next = limit + 10
|
// Zähler: bei Suche aus, bei Fehler "–", vor der ersten Antwort fehlt die Zahl
|
||||||
setLimit(next)
|
const tabs = LIST_TABS.map((tab) => {
|
||||||
setFilters((prev) => ({ ...prev, limit: next }))
|
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 (
|
return (
|
||||||
@@ -80,86 +210,60 @@ export default function TicketsPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Tickets"
|
title="Tickets"
|
||||||
subtitle="Alle Arbeitsaufträge auf einen Blick"
|
subtitle="Alle Arbeitsaufträge auf einen Blick"
|
||||||
actions={
|
actions={<Button variant="primary" type="button" onClick={openCreate}>Neues Ticket</Button>}
|
||||||
<>
|
|
||||||
<Button variant="ghost" onClick={() => setShowAdvanced((s) => !s)}><FaSliders /> Filter</Button>
|
|
||||||
<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card pad className="mb-2" style={{ marginBottom: 16 }}>
|
<Tabs
|
||||||
<form className="toolbar" onSubmit={applyFilters} style={{ marginBottom: showAdvanced ? 16 : 0 }}>
|
tabs={tabs}
|
||||||
|
active={state.tab}
|
||||||
|
onChange={(id) => { if (id !== state.tab) setFilter({ tab: id }) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="toolbar"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
commitSearch(searchText)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="form-control search-input"
|
className="input search"
|
||||||
|
aria-label="Tickets durchsuchen"
|
||||||
placeholder="Suchen: Kunde, Betreff, Angefragt von oder WOID"
|
placeholder="Suchen: Kunde, Betreff, Angefragt von oder WOID"
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button variant="primary" type="submit">Anwenden</Button>
|
<select
|
||||||
</form>
|
className="select select-quiet"
|
||||||
|
aria-label="Art"
|
||||||
{showAdvanced && (
|
value={state.typeId}
|
||||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 16 }}>
|
onChange={(e) => setFilter({ typeId: e.target.value })}
|
||||||
<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) => (
|
{TYPE_FILTERS.map((filter) => (
|
||||||
<option key={filter.id} value={filter.id}>{filter.label}</option>
|
<option key={filter.id} value={filter.id}>{filter.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<label className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 14 }}>
|
<span className="grow" />
|
||||||
<input type="checkbox" checked={urgentOnly} onChange={(e) => setUrgentOnly(e.target.checked)} />
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={state.urgentOnly}
|
||||||
|
onChange={(e) => setFilter({ urgentOnly: e.target.checked })}
|
||||||
|
/>
|
||||||
Nur dringende
|
Nur dringende
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</form>
|
||||||
<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 ? (
|
{content}
|
||||||
<div className="empty"><FaSpinner className="spinner" /></div>
|
|
||||||
) : error ? (
|
<CreateTicketModal
|
||||||
<Card><div className="text-red">Fehler: {error}</div></Card>
|
isOpen={showCreate}
|
||||||
) : workorders.length === 0 ? (
|
onClose={() => setShowCreate(false)}
|
||||||
<Card>
|
onCreate={handleCreate}
|
||||||
<EmptyState
|
customers={customers}
|
||||||
title="Keine Tickets"
|
employees={employees}
|
||||||
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} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CreateTicketModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} onCreate={handleCreate} customers={customers} />
|
|
||||||
</div>
|
</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-subtitle { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
|
||||||
.page-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
|
.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) --- */
|
/* --- Generic data list rows (Kunden/Finanzen/Dokumente) --- */
|
||||||
.list { display: flex; flex-direction: column; }
|
.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 (bis P3): die 7-Spalten-Woche scrollt waagerecht im Container, nicht die Seite */
|
||||||
.planboard-scroll { overflow-x: auto; }
|
.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) */
|
/* Neues Ticket: Datum + Uhrzeit nebeneinander (heller Kalender, kein color-scheme: dark) */
|
||||||
.datetime-pair { display: flex; gap: 8px; }
|
.datetime-pair { display: flex; gap: 8px; }
|
||||||
.datetime-pair .form-control, .datetime-pair .input { flex: 1; min-width: 0; }
|
.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; }
|
.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) ===== */
|
/* ===== KOMPAT (bis P10) ===== */
|
||||||
/* Uebergangsschicht: alte Klassen im neuen Look, damit Seiten und Dialoge, die noch nicht umgebaut sind, weiss, kastenlos */
|
/* 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 */
|
/* 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); }
|
.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 */
|
/* 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); }
|
.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;
|
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 { 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-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); }
|
.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 { padding: 0 8px; }
|
||||||
.col-6 { width: 50%; }
|
.col-6 { width: 50%; }
|
||||||
@media (max-width: 768px) { .col-6 { width: 100%; } }
|
@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 */
|
/* 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; }
|
.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 assert from 'node:assert/strict'
|
||||||
import { loadModule } from './load.mjs'
|
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', () => {
|
test('statusTone: Ton je gespeichertem Wert', () => {
|
||||||
for (const s of ['Open', '', undefined, null, 'Completed']) assert.equal(statusTone(s), '')
|
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 ['Closed', 'Zugesagt']) assert.equal(statusTone(s), 'green')
|
||||||
for (const s of ['Cancelled', 'Aborted', 'Abgesagt']) assert.equal(statusTone(s), 'plain')
|
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