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