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