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
@@ -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',
|
||||
}
|
||||
Reference in new issue
Block a user