Tickets: vier Ticket-Dialoge auf Dialog (Esc, Sperre beim Speichern), Unterstuetzung als Feldblock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 06:02:55 +00:00
1 parent 39dfb386ac
commit 200d68f20d
6 files changed
+439 -436

No files matched your search

+133 -143
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useMemo } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useEmployees } from '../hooks/useEmployees'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useScrollIntoView } from '../hooks/useScrollIntoView'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import {
SERVICE_TYPES,
@@ -11,7 +10,7 @@ import {
rankSuggestions,
} from '../lib/ticketForm'
import { priorityFor } from '../lib/ticketStatus'
import { AutocompleteInput, DateTimeInput } from './ui'
import { AutocompleteInput, Button, DateTimeInput, Dialog, Field } from './ui'
// Neue Tickets gibt es aktuell nur fuer diese Arten; alte Tickets behalten ihre Art.
// Migration = ein Kunde, den wir von einem anderen Anbieter uebernehmen.
@@ -36,14 +35,18 @@ const emptyForm = () => ({
details: ''
})
export default function CreateTicketModal({ isOpen, onClose, onCreate, customers = [] }) {
const { employees } = useEmployees()
// Stabile Vorgabe, damit die Vorschlagsliste nicht bei jedem Render neu entsteht
const NO_EMPLOYEES = []
// `employees` kommt von der Seite (useEmployees laeuft dort einmal), der Dialog ruft den Hook nicht selbst auf
export default function CreateTicketModal({ isOpen, onClose, onCreate, customers = [], employees = NO_EMPLOYEES }) {
const [formData, setFormData] = useState(emptyForm)
const [recentTickets, setRecentTickets] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const errorRef = useRef(null)
useScrollIntoView(errorRef, error)
// Reset error when modal opens
useEffect(() => {
@@ -122,145 +125,132 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
if (!isOpen) return null
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content">
<h2 className="mb-2">Neues Ticket</h2>
<Dialog
as="form"
wide
title="Neues Ticket"
busy={loading}
onClose={onClose}
onSubmit={handleSubmit}
footer={
<>
<span className="grow" />
<Button variant="primary" type="submit" disabled={loading}>
{loading ? 'Wird angelegt …' : 'Ticket anlegen'}
</Button>
</>
}
>
{error && (
<div className="msg err" role="alert" ref={errorRef}>
{error}
</div>
)}
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<div className="field-row">
<Field label="Kunde" htmlFor="ticket-customer">
<select
id="ticket-customer"
className="select"
value={formData.customerId}
onChange={(e) => handleChange('customerId', e.target.value)}
required
>
<option value="">Kunde wählen</option>
{customers.map(c => (
<option key={c.$id} value={c.$id}>({c.code || ''}) {c.name || 'Unnamed'}</option>
))}
</select>
</Field>
<form onSubmit={handleSubmit}>
<div className="row">
<div className="col col-6">
<div className="form-group">
<label className="form-label">Kunde</label>
<select
className="form-control"
value={formData.customerId}
onChange={(e) => handleChange('customerId', e.target.value)}
required
>
<option value="">Kunde wählen</option>
{customers.map(c => (
<option key={c.$id} value={c.$id}>({c.code || ''}) {c.name || 'Unnamed'}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Art</label>
<select
className="form-control"
value={formData.type}
onChange={(e) => handleChange('type', e.target.value)}
>
{TICKET_TYPES.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.urgent}
onChange={(e) => handleChange('urgent', e.target.checked)}
/>
Dringend
</label>
</div>
<div className="form-group automatic-assignment-note">
<label className="form-label">Zuweisung</label>
<p>
{employees.length > 0
? `Automatisch an den Mitarbeiter mit der geringsten Anzahl offener Tickets (${employees.length} verfügbar).`
: 'Es ist noch kein Mitarbeiter verfügbar. Das Ticket bleibt zunächst offen.'}
</p>
</div>
</div>
<div className="col col-6">
<div className="form-group">
<label className="form-label">Betreff</label>
<input
type="text"
className="form-control"
placeholder="Worum geht es?"
value={formData.topic}
onChange={(e) => handleChange('topic', e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="ticket-requested-by">Angefragt von</label>
<AutocompleteInput
id="ticket-requested-by"
placeholder="Name"
value={formData.requestedBy}
onChange={(value) => handleChange('requestedBy', value)}
getSuggestions={getSuggestions}
required
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="ticket-start-date">Beginn</label>
<DateTimeInput
id="ticket-start-date"
timeLabel="Uhrzeit Beginn"
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="ticket-end-date">Deadline</label>
<DateTimeInput
id="ticket-end-date"
timeLabel="Uhrzeit Deadline"
min={formData.startDate}
date={formData.deadline}
time={formData.endTime}
onDateChange={(value) => handleChange('deadline', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</div>
</div>
</div>
<div className="form-group">
<label className="form-label">Beschreibung</label>
<textarea
className="form-control"
rows={5}
placeholder="Was soll gemacht werden?"
value={formData.details}
onChange={(e) => handleChange('details', e.target.value)}
/>
</div>
<div className="text-center mt-2">
<button
type="submit"
className="btn btn-dark"
disabled={loading}
>
{loading ? 'Wird angelegt …' : 'Ticket anlegen'}
</button>
</div>
</form>
<Field label="Art" htmlFor="ticket-type">
<select
id="ticket-type"
className="select"
value={formData.type}
onChange={(e) => handleChange('type', e.target.value)}
>
{TICKET_TYPES.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</Field>
</div>
</div>
<Field label="Betreff" htmlFor="ticket-topic">
<input
id="ticket-topic"
type="text"
className="input"
placeholder="Worum geht es?"
value={formData.topic}
onChange={(e) => handleChange('topic', e.target.value)}
required
/>
</Field>
<Field label="Angefragt von" htmlFor="ticket-requested-by">
<AutocompleteInput
id="ticket-requested-by"
placeholder="Name"
value={formData.requestedBy}
onChange={(value) => handleChange('requestedBy', value)}
getSuggestions={getSuggestions}
required
/>
</Field>
<div className="field-row">
<Field label="Beginn" htmlFor="ticket-start-date">
<DateTimeInput
id="ticket-start-date"
timeLabel="Uhrzeit Beginn"
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</Field>
<Field label="Deadline" htmlFor="ticket-end-date">
<DateTimeInput
id="ticket-end-date"
timeLabel="Uhrzeit Deadline"
min={formData.startDate}
date={formData.deadline}
time={formData.endTime}
onDateChange={(value) => handleChange('deadline', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</Field>
</div>
<Field label="Beschreibung" htmlFor="ticket-details">
<textarea
id="ticket-details"
className="textarea"
rows={5}
placeholder="Was soll gemacht werden?"
value={formData.details}
onChange={(e) => handleChange('details', e.target.value)}
/>
</Field>
<label className="check">
<input
type="checkbox"
checked={formData.urgent}
onChange={(e) => handleChange('urgent', e.target.checked)}
/>
Dringend
</label>
<Field
label="Zuweisung"
hint={employees.length > 0
? `Automatisch an den Mitarbeiter mit der geringsten Anzahl offener Tickets (${employees.length} verfügbar).`
: 'Es ist noch kein Mitarbeiter verfügbar. Das Ticket bleibt zunächst offen.'}
/>
</Dialog>
)
}
+147 -155
View File
@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useState, useEffect, useRef } from 'react'
import { useScrollIntoView } from '../hooks/useScrollIntoView'
import { useAuth } from '../context/AuthContext'
import { SERVICE_TYPES, todayIso, isoToGermanDate, timeToWoms, minutesBetween } from '../lib/ticketForm'
import { TICKET_STATUSES, statusLabel } from '../lib/ticketStatus'
import { DateTimeInput, DurationSelect } from './ui'
import { Button, DateTimeInput, Dialog, DurationSelect, Field } from './ui'
import { roundUpToStep, isValidStep } from '../lib/timeSteps'
import { validateSupporters } from '../lib/support'
import SupportersEditor from './SupportersEditor'
@@ -35,7 +35,10 @@ const initialForm = (workorder, newStatus) => ({
supporters: []
})
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate, aboHoursAllowed = true }) {
// Mitarbeiterliste der Seite (useEmployees laeuft dort einmal); der Editor ruft den Hook nicht selbst auf
const NO_EMPLOYEES_STATE = { employees: [], loading: false, error: null }
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate, aboHoursAllowed = true, employeesState = NO_EMPLOYEES_STATE }) {
const { user } = useAuth()
const isAcquisition = workorder?.type === 'Akquise'
@@ -51,6 +54,8 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [autoCalculate, setAutoCalculate] = useState(true)
const errorRef = useRef(null)
useScrollIntoView(errorRef, error)
// Reset form wenn Modal geöffnet wird
useEffect(() => {
@@ -148,159 +153,146 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
if (!isOpen || !workorder) return null
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content">
<h2 className="mb-2">Neues Arbeitsblatt – Ticket {workorder.woid}</h2>
<Dialog
as="form"
wide
title={`Neues Arbeitsblatt – Ticket ${workorder.woid}`}
busy={loading}
onClose={onClose}
onSubmit={handleSubmit}
footer={
<>
<span className="grow" />
<Button variant="primary" type="submit" disabled={loading}>
{loading ? 'Speichert …' : 'Speichern'}
</Button>
</>
}
>
{error && (
<div className="msg err" role="alert" ref={errorRef}>
{error}
</div>
)}
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<div className="field-row">
<Field label="Art" htmlFor="worksheet-service-type">
<select
id="worksheet-service-type"
className="select"
value={formData.serviceType}
onChange={(e) => handleChange('serviceType', e.target.value)}
required
>
{SERVICE_TYPES.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</Field>
<form onSubmit={handleSubmit}>
<div className="row">
<div className="col col-6">
<div className="form-group">
<label className="form-label">Art</label>
<select
className="form-control"
value={formData.serviceType}
onChange={(e) => handleChange('serviceType', e.target.value)}
required
>
{SERVICE_TYPES.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">{isAcquisition ? 'Akquise-Ergebnis' : 'Neuer Status'}</label>
<select
className="form-control"
value={formData.newStatus}
onChange={(e) => handleChange('newStatus', e.target.value)}
required
>
{statusOptions.map(status => (
<option key={status.value} value={status.value}>{status.label}</option>
))}
</select>
{isAcquisition && (
<small style={{ color: 'var(--text-2)', fontSize: '12px' }}>
"Zugesagt" macht den Lead automatisch zum festen Kunden.
</small>
)}
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={formData.isComment}
onChange={(e) => handleChange('isComment', e.target.checked)}
/>
Nur Kommentar (keine Arbeitszeit)
</label>
</div>
{aboHoursAllowed && (
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }} title="Zeit wird vom Stundenguthaben des Wartungs-Abos abgezogen und im Kundenportal angezeigt">
<input
type="checkbox"
checked={formData.countsToPlan && !formData.isComment}
disabled={formData.isComment}
onChange={(e) => handleChange('countsToPlan', e.target.checked)}
/>
Vom Abo-Stundenkonto abziehen
</label>
</div>
)}
</div>
<div className="col col-6">
<div className="form-group">
<label className="form-label" htmlFor="worksheet-start-date">Beginn</label>
<DateTimeInput
id="worksheet-start-date"
timeLabel="Uhrzeit Beginn"
required
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-end-date">Ende</label>
<DateTimeInput
id="worksheet-end-date"
timeLabel="Uhrzeit Ende"
required
min={formData.startDate}
date={formData.endDate}
time={formData.endTime}
onDateChange={(value) => handleChange('endDate', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-total">Arbeitszeit</label>
<DurationSelect
id="worksheet-total"
value={formData.isComment ? 0 : formData.totalTime}
onChange={(value) => handleChange('totalTime', value)}
disabled={formData.isComment}
/>
<small style={{ color: 'var(--text-2)', fontSize: '12px' }}>
{autoCalculate && formData.startTime && formData.endTime
? '✓ Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: autoCalculate ? 'Beginn und Ende eintragen oder Zeit wählen' : 'Von Hand gewählt'}
</small>
</div>
</div>
</div>
{!formData.isComment && (
<SupportersEditor
value={formData.supporters}
onChange={(list) => handleChange('supporters', list)}
excludeUserId={user?.$id}
minutes={formData.totalTime}
/>
)}
<div className="form-group">
<label className="form-label">Was wurde gemacht?</label>
<textarea
className="form-control"
rows={5}
placeholder="Beschreibe die durchgeführten Arbeiten..."
value={formData.details}
onChange={(e) => handleChange('details', e.target.value)}
required
/>
</div>
<div className="text-center mt-2">
<button
type="submit"
className="btn btn-dark"
disabled={loading}
>
{loading ? 'Speichert …' : 'Speichern'}
</button>
</div>
</form>
<Field
label={isAcquisition ? 'Akquise-Ergebnis' : 'Neuer Status'}
htmlFor="worksheet-new-status"
hint={isAcquisition ? '"Zugesagt" macht den Lead automatisch zum festen Kunden.' : undefined}
>
<select
id="worksheet-new-status"
className="select"
value={formData.newStatus}
onChange={(e) => handleChange('newStatus', e.target.value)}
required
>
{statusOptions.map(status => (
<option key={status.value} value={status.value}>{status.label}</option>
))}
</select>
</Field>
</div>
</div>
<label className="check">
<input
type="checkbox"
checked={formData.isComment}
onChange={(e) => handleChange('isComment', e.target.checked)}
/>
Nur Kommentar (keine Arbeitszeit)
</label>
{aboHoursAllowed && (
<label className="check" title="Zeit wird vom Stundenguthaben des Wartungs-Abos abgezogen und im Kundenportal angezeigt">
<input
type="checkbox"
checked={formData.countsToPlan && !formData.isComment}
disabled={formData.isComment}
onChange={(e) => handleChange('countsToPlan', e.target.checked)}
/>
Vom Abo-Stundenkonto abziehen
</label>
)}
<div className="field-row">
<Field label="Beginn" htmlFor="worksheet-start-date">
<DateTimeInput
id="worksheet-start-date"
timeLabel="Uhrzeit Beginn"
required
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</Field>
<Field label="Ende" htmlFor="worksheet-end-date">
<DateTimeInput
id="worksheet-end-date"
timeLabel="Uhrzeit Ende"
required
min={formData.startDate}
date={formData.endDate}
time={formData.endTime}
onDateChange={(value) => handleChange('endDate', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</Field>
</div>
<Field
label="Arbeitszeit"
htmlFor="worksheet-total"
hint={autoCalculate && formData.startTime && formData.endTime
? 'Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: autoCalculate ? 'Beginn und Ende eintragen oder Zeit wählen' : 'Von Hand gewählt'}
>
<DurationSelect
id="worksheet-total"
value={formData.isComment ? 0 : formData.totalTime}
onChange={(value) => handleChange('totalTime', value)}
disabled={formData.isComment}
/>
</Field>
{!formData.isComment && (
<SupportersEditor
value={formData.supporters}
onChange={(list) => handleChange('supporters', list)}
excludeUserId={user?.$id}
minutes={formData.totalTime}
employeesState={employeesState}
/>
)}
<Field label="Was wurde gemacht?" htmlFor="worksheet-details">
<textarea
id="worksheet-details"
className="textarea"
rows={5}
placeholder="Beschreibe die durchgeführten Arbeiten..."
value={formData.details}
onChange={(e) => handleChange('details', e.target.value)}
required
/>
</Field>
</Dialog>
)
}
+52 -43
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useEffect, useRef, useState } from 'react'
import { useScrollIntoView } from '../hooks/useScrollIntoView'
import { parseSupporters, supportMinutes } from '../lib/support'
import { stepLabel } from '../lib/timeSteps'
import { Button, Dialog, Field } from './ui'
/* "Ich habe unterstuetzt": die eigene Unterstuetzung bei einem fremden Arbeitsblatt
eintragen, aendern oder entfernen. Andere Eintraege und die Zeiten bleiben unberuehrt. */
@@ -10,6 +11,14 @@ 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)
@@ -33,48 +42,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>
)
}
+26 -20
View File
@@ -1,14 +1,16 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useEmployees } from '../hooks/useEmployees'
import { supportMinutes } from '../lib/support'
import { stepLabel } from '../lib/timeSteps'
// Stabile Vorgabe, damit `choices` und die Effekte der Aufrufer nicht bei jedem Render neu entstehen
const NO_EMPLOYEES_STATE = { employees: [], loading: false, error: null }
/* Haken "Unterstuetzung" mit Zeilen Mitarbeiter + "Wobei?". Nur fuer die Person, die das
Arbeitsblatt angelegt hat (sie selbst steht nicht in der Auswahl). Die Mitarbeiterliste
laedt erst hier, damit nicht jede Ticket-Karte sie abruft. */
export default function SupportersEditor({ value, onChange, excludeUserId, minutes }) {
const { employees, loading, error } = useEmployees()
kommt von der Seite (employeesState = Werte von useEmployees, dort einmal geladen),
der Editor ruft den Hook nicht selbst auf. */
export default function SupportersEditor({ value, onChange, excludeUserId, minutes, employeesState = NO_EMPLOYEES_STATE }) {
const { employees, loading, error } = employeesState
const [enabled, setEnabled] = useState(value.length > 0)
const perPerson = stepLabel(supportMinutes(minutes))
const choices = employees.filter((e) => e.userId && e.userId !== excludeUserId)
@@ -29,20 +31,20 @@ export default function SupportersEditor({ value, onChange, excludeUserId, minut
}
return (
<div className="form-group supporters-editor">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div className="field supporters-editor">
<label className="check">
<input type="checkbox" checked={enabled} onChange={(e) => toggle(e.target.checked)} />
Unterstützung
</label>
{enabled && (
<div style={{ marginTop: 8 }}>
<>
{!loading && (error || !choices.length) && (
<p className="text-grey" style={{ fontSize: 13 }}>Mitarbeiterliste konnte nicht geladen werden.</p>
<div className="hint">Mitarbeiterliste konnte nicht geladen werden.</div>
)}
{value.map((row, index) => (
<div key={index} className="flex gap-2" style={{ marginBottom: 8, alignItems: 'center' }}>
<div key={index} className="supporter-row">
<select
className="form-control"
className="select"
aria-label="Mitarbeiter"
value={row.employeeId}
onChange={(e) => pick(index, e.target.value)}
@@ -53,23 +55,27 @@ export default function SupportersEditor({ value, onChange, excludeUserId, minut
.map((e) => <option key={e.userId} value={e.userId}>{e.displayName}</option>)}
</select>
<input
className="form-control"
className="input"
aria-label="Wobei?"
placeholder="Wobei?"
maxLength={200}
value={row.task}
onChange={(e) => update(index, { task: e.target.value })}
/>
<span className="text-grey" style={{ whiteSpace: 'nowrap', fontSize: 13 }}>= {perPerson}</span>
<button type="button" className="btn btn-sm" aria-label="Zeile entfernen" onClick={() => remove(index)}>
<FaTimes />
</button>
<div className="sup-meta">
<span>= {perPerson}</span>
<button type="button" className="link" aria-label="Zeile entfernen" onClick={() => remove(index)}>
Entfernen
</button>
</div>
</div>
))}
<button type="button" className="btn btn-sm" onClick={() => onChange([...value, { employeeId: '', name: '', task: '' }])}>
+ weitere Person
</button>
</div>
<div>
<button type="button" className="link" onClick={() => onChange([...value, { employeeId: '', name: '', task: '' }])}>
+ weitere Person
</button>
</div>
</>
)}
</div>
)
+79 -75
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useRef, useState } from 'react'
import { useScrollIntoView } from '../hooks/useScrollIntoView'
import { isoToGermanDate, timeToWoms, germanDateToIso, womsTimeToInput, minutesBetween, isGitWorksheet, needsTimeEntry } from '../lib/ticketForm'
import { DateTimeInput, DurationSelect } from './ui'
import { Button, DateTimeInput, Dialog, DurationSelect, Field } from './ui'
import { roundUpToStep, isValidStep } from '../lib/timeSteps'
import { parseSupporters, validateSupporters } from '../lib/support'
import SupportersEditor from './SupportersEditor'
@@ -14,11 +14,14 @@ const formFromWorksheet = (worksheet) => ({
endTime: womsTimeToInput(worksheet.endTime)
})
// Mitarbeiterliste der Seite (useEmployees laeuft dort einmal); der Editor ruft den Hook nicht selbst auf
const NO_EMPLOYEES_STATE = { employees: [], loading: false, error: null }
/**
* Beginn, Ende und Arbeitszeit eines Arbeitsblatts nachtraeglich aendern.
* Wer das darf, entscheidet canEditWorksheetTimes (lib/ticketForm).
*/
export default function WorksheetTimesModal({ worksheet, currentUserId, onClose, onSave }) {
export default function WorksheetTimesModal({ worksheet, currentUserId, onClose, onSave, employeesState = NO_EMPLOYEES_STATE }) {
const isGit = isGitWorksheet(worksheet)
const [formData, setFormData] = useState(() => formFromWorksheet(worksheet))
// null = die Arbeitszeit folgt Beginn und Ende (aufgerundet auf 15 Minuten). Passte sie
@@ -33,6 +36,8 @@ export default function WorksheetTimesModal({ worksheet, currentUserId, onClose,
const [supporters, setSupporters] = useState(() => parseSupporters(worksheet.supporters))
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const errorRef = useRef(null)
useScrollIntoView(errorRef, error)
const calculatedMinutes = minutesBetween(formData.startTime, formData.endTime)
const calculatedTotal = calculatedMinutes === null ? null : roundUpToStep(calculatedMinutes)
@@ -85,82 +90,81 @@ export default function WorksheetTimesModal({ worksheet, currentUserId, onClose,
setSaving(false)
}
// Kurzer Titel: auf dem Handy laeuft ein langer unter das Schliessen-X
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content" style={{ maxWidth: 560 }}>
{/* Kurzer Titel: auf dem Handy laeuft ein langer unter das Schliessen-X */}
<h2 className="mb-2">{needsTimeEntry(worksheet) ? 'Zeit nachtragen' : 'Zeiten bearbeiten'}</h2>
<p className="text-grey" style={{ fontSize: '14px', marginBottom: '16px' }}>
WSID {worksheet.wsid}
{isGit && <><br />Das Ende ist der Zeitpunkt des Git-Pushs. Trag ein, wann die Arbeit begonnen hat.</>}
</p>
<Dialog
as="form"
title={needsTimeEntry(worksheet) ? 'Zeit nachtragen' : 'Zeiten bearbeiten'}
busy={saving}
onClose={onClose}
onSubmit={handleSubmit}
footer={
<>
<span className="grow" />
<Button variant="primary" type="submit" disabled={saving}>
{saving ? 'Speichert …' : 'Speichern'}
</Button>
</>
}
>
{error && (
<div className="msg err" role="alert" ref={errorRef}>
{error}
</div>
)}
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<p className="muted">
WSID {worksheet.wsid}
{isGit && <><br />Das Ende ist der Zeitpunkt des Git-Pushs. Trag ein, wann die Arbeit begonnen hat.</>}
</p>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-times-start">Beginn</label>
<DateTimeInput
id="worksheet-times-start"
timeLabel="Uhrzeit Beginn"
required
timeRequired={isGit}
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</div>
<Field label="Beginn" htmlFor="worksheet-times-start">
<DateTimeInput
id="worksheet-times-start"
timeLabel="Uhrzeit Beginn"
required
timeRequired={isGit}
date={formData.startDate}
time={formData.startTime}
onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)}
/>
</Field>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-times-end">Ende</label>
<DateTimeInput
id="worksheet-times-end"
timeLabel="Uhrzeit Ende"
required
min={formData.startDate}
date={formData.endDate}
time={formData.endTime}
onDateChange={(value) => handleChange('endDate', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</div>
<Field label="Ende" htmlFor="worksheet-times-end">
<DateTimeInput
id="worksheet-times-end"
timeLabel="Uhrzeit Ende"
required
min={formData.startDate}
date={formData.endDate}
time={formData.endTime}
onDateChange={(value) => handleChange('endDate', value)}
onTimeChange={(value) => handleChange('endTime', value)}
/>
</Field>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-times-total">Arbeitszeit</label>
<DurationSelect
id="worksheet-times-total"
value={totalTime}
onChange={(value) => setManualTotal(value)}
/>
<small style={{ color: 'var(--text-2)', fontSize: '12px' }}>
{manualTotal === null && calculatedTotal !== null
? '✓ Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: 'Von Hand gewählt'}
</small>
</div>
<Field
label="Arbeitszeit"
htmlFor="worksheet-times-total"
hint={manualTotal === null && calculatedTotal !== null
? 'Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: 'Von Hand gewählt'}
>
<DurationSelect
id="worksheet-times-total"
value={totalTime}
onChange={(value) => setManualTotal(value)}
/>
</Field>
<SupportersEditor
value={supporters}
onChange={setSupporters}
excludeUserId={currentUserId}
minutes={totalTime}
/>
<div className="text-center mt-2">
<button type="submit" className="btn btn-dark" disabled={saving}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
</div>
</form>
</div>
</div>
<SupportersEditor
value={supporters}
onChange={setSupporters}
excludeUserId={currentUserId}
minutes={totalTime}
employeesState={employeesState}
/>
</Dialog>
)
}
+2
View File
@@ -1051,6 +1051,8 @@ button.link { font-weight: 500; } /* font: inherit setzt sonst das Gewicht von .
/* ---------- Unterstuetzung im Dialog ---------- */
.supporter-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px 12px; align-items: center; margin-top: 8px; }
.supporter-row .sup-meta { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; font-size: 13px; color: var(--text-2); }
/* Kaestchen im Dialog stehen in einer Spalte (flex): ohne das waere die ganze Zeile klickbar */
.dialog-body > .check, .supporters-editor > .check { align-self: flex-start; }
@media (max-width: 860px) {
.ws { grid-template-columns: minmax(0, 1fr) auto; }