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
+237 -234

No files matched your search

+66 -76
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,26 +125,33 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
if (!isOpen) return null if (!isOpen) return null
return ( return (
<div className="overlay"> <Dialog
<span className="overlay-close" onClick={onClose}> as="form"
<FaTimes /> wide
</span> title="Neues Ticket"
<div className="overlay-content"> busy={loading}
<h2 className="mb-2">Neues Ticket</h2> onClose={onClose}
onSubmit={handleSubmit}
footer={
<>
<span className="grow" />
<Button variant="primary" type="submit" disabled={loading}>
{loading ? 'Wird angelegt …' : 'Ticket anlegen'}
</Button>
</>
}
>
{error && ( {error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}> <div className="msg err" role="alert" ref={errorRef}>
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit}> <div className="field-row">
<div className="row"> <Field label="Kunde" htmlFor="ticket-customer">
<div className="col col-6">
<div className="form-group">
<label className="form-label">Kunde</label>
<select <select
className="form-control" id="ticket-customer"
className="select"
value={formData.customerId} value={formData.customerId}
onChange={(e) => handleChange('customerId', e.target.value)} onChange={(e) => handleChange('customerId', e.target.value)}
required required
@@ -151,12 +161,12 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
<option key={c.$id} value={c.$id}>({c.code || ''}) {c.name || 'Unnamed'}</option> <option key={c.$id} value={c.$id}>({c.code || ''}) {c.name || 'Unnamed'}</option>
))} ))}
</select> </select>
</div> </Field>
<div className="form-group"> <Field label="Art" htmlFor="ticket-type">
<label className="form-label">Art</label>
<select <select
className="form-control" id="ticket-type"
className="select"
value={formData.type} value={formData.type}
onChange={(e) => handleChange('type', e.target.value)} onChange={(e) => handleChange('type', e.target.value)}
> >
@@ -164,44 +174,22 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
<option key={type.value} value={type.value}>{type.label}</option> <option key={type.value} value={type.value}>{type.label}</option>
))} ))}
</select> </select>
</Field>
</div> </div>
<div className="form-group"> <Field label="Betreff" htmlFor="ticket-topic">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.urgent}
onChange={(e) => handleChange('urgent', e.target.checked)}
/>
Dringend
</label>
</div>
<div className="form-group automatic-assignment-note">
<label className="form-label">Zuweisung</label>
<p>
{employees.length > 0
? `Automatisch an den Mitarbeiter mit der geringsten Anzahl offener Tickets (${employees.length} verfügbar).`
: 'Es ist noch kein Mitarbeiter verfügbar. Das Ticket bleibt zunächst offen.'}
</p>
</div>
</div>
<div className="col col-6">
<div className="form-group">
<label className="form-label">Betreff</label>
<input <input
id="ticket-topic"
type="text" type="text"
className="form-control" className="input"
placeholder="Worum geht es?" placeholder="Worum geht es?"
value={formData.topic} value={formData.topic}
onChange={(e) => handleChange('topic', e.target.value)} onChange={(e) => handleChange('topic', e.target.value)}
required required
/> />
</div> </Field>
<div className="form-group"> <Field label="Angefragt von" htmlFor="ticket-requested-by">
<label className="form-label" htmlFor="ticket-requested-by">Angefragt von</label>
<AutocompleteInput <AutocompleteInput
id="ticket-requested-by" id="ticket-requested-by"
placeholder="Name" placeholder="Name"
@@ -210,10 +198,10 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
getSuggestions={getSuggestions} getSuggestions={getSuggestions}
required required
/> />
</div> </Field>
<div className="form-group"> <div className="field-row">
<label className="form-label" htmlFor="ticket-start-date">Beginn</label> <Field label="Beginn" htmlFor="ticket-start-date">
<DateTimeInput <DateTimeInput
id="ticket-start-date" id="ticket-start-date"
timeLabel="Uhrzeit Beginn" timeLabel="Uhrzeit Beginn"
@@ -222,10 +210,9 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
onDateChange={handleStartDateChange} onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)} onTimeChange={(value) => handleChange('startTime', value)}
/> />
</div> </Field>
<div className="form-group"> <Field label="Deadline" htmlFor="ticket-end-date">
<label className="form-label" htmlFor="ticket-end-date">Deadline</label>
<DateTimeInput <DateTimeInput
id="ticket-end-date" id="ticket-end-date"
timeLabel="Uhrzeit Deadline" timeLabel="Uhrzeit Deadline"
@@ -235,32 +222,35 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
onDateChange={(value) => handleChange('deadline', value)} onDateChange={(value) => handleChange('deadline', value)}
onTimeChange={(value) => handleChange('endTime', value)} onTimeChange={(value) => handleChange('endTime', value)}
/> />
</div> </Field>
</div>
</div> </div>
<div className="form-group"> <Field label="Beschreibung" htmlFor="ticket-details">
<label className="form-label">Beschreibung</label>
<textarea <textarea
className="form-control" id="ticket-details"
className="textarea"
rows={5} rows={5}
placeholder="Was soll gemacht werden?" placeholder="Was soll gemacht werden?"
value={formData.details} value={formData.details}
onChange={(e) => handleChange('details', e.target.value)} onChange={(e) => handleChange('details', e.target.value)}
/> />
</div> </Field>
<div className="text-center mt-2"> <label className="check">
<button <input
type="submit" type="checkbox"
className="btn btn-dark" checked={formData.urgent}
disabled={loading} onChange={(e) => handleChange('urgent', e.target.checked)}
> />
{loading ? 'Wird angelegt …' : 'Ticket anlegen'} Dringend
</button> </label>
</div>
</form> <Field
</div> label="Zuweisung"
</div> hint={employees.length > 0
? `Automatisch an den Mitarbeiter mit der geringsten Anzahl offener Tickets (${employees.length} verfügbar).`
: 'Es ist noch kein Mitarbeiter verfügbar. Das Ticket bleibt zunächst offen.'}
/>
</Dialog>
) )
} }
+60 -68
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,26 +153,33 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
if (!isOpen || !workorder) return null if (!isOpen || !workorder) return null
return ( return (
<div className="overlay"> <Dialog
<span className="overlay-close" onClick={onClose}> as="form"
<FaTimes /> wide
</span> title={`Neues Arbeitsblatt – Ticket ${workorder.woid}`}
<div className="overlay-content"> busy={loading}
<h2 className="mb-2">Neues Arbeitsblatt – Ticket {workorder.woid}</h2> onClose={onClose}
onSubmit={handleSubmit}
footer={
<>
<span className="grow" />
<Button variant="primary" type="submit" disabled={loading}>
{loading ? 'Speichert …' : 'Speichern'}
</Button>
</>
}
>
{error && ( {error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}> <div className="msg err" role="alert" ref={errorRef}>
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit}> <div className="field-row">
<div className="row"> <Field label="Art" htmlFor="worksheet-service-type">
<div className="col col-6">
<div className="form-group">
<label className="form-label">Art</label>
<select <select
className="form-control" id="worksheet-service-type"
className="select"
value={formData.serviceType} value={formData.serviceType}
onChange={(e) => handleChange('serviceType', e.target.value)} onChange={(e) => handleChange('serviceType', e.target.value)}
required required
@@ -176,12 +188,16 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
<option key={type.value} value={type.value}>{type.label}</option> <option key={type.value} value={type.value}>{type.label}</option>
))} ))}
</select> </select>
</div> </Field>
<div className="form-group"> <Field
<label className="form-label">{isAcquisition ? 'Akquise-Ergebnis' : 'Neuer Status'}</label> label={isAcquisition ? 'Akquise-Ergebnis' : 'Neuer Status'}
htmlFor="worksheet-new-status"
hint={isAcquisition ? '"Zugesagt" macht den Lead automatisch zum festen Kunden.' : undefined}
>
<select <select
className="form-control" id="worksheet-new-status"
className="select"
value={formData.newStatus} value={formData.newStatus}
onChange={(e) => handleChange('newStatus', e.target.value)} onChange={(e) => handleChange('newStatus', e.target.value)}
required required
@@ -190,15 +206,10 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
<option key={status.value} value={status.value}>{status.label}</option> <option key={status.value} value={status.value}>{status.label}</option>
))} ))}
</select> </select>
{isAcquisition && ( </Field>
<small style={{ color: 'var(--text-2)', fontSize: '12px' }}>
"Zugesagt" macht den Lead automatisch zum festen Kunden.
</small>
)}
</div> </div>
<div className="form-group"> <label className="check">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input <input
type="checkbox" type="checkbox"
checked={formData.isComment} checked={formData.isComment}
@@ -206,11 +217,9 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
/> />
Nur Kommentar (keine Arbeitszeit) Nur Kommentar (keine Arbeitszeit)
</label> </label>
</div>
{aboHoursAllowed && ( {aboHoursAllowed && (
<div className="form-group"> <label className="check" title="Zeit wird vom Stundenguthaben des Wartungs-Abos abgezogen und im Kundenportal angezeigt">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }} title="Zeit wird vom Stundenguthaben des Wartungs-Abos abgezogen und im Kundenportal angezeigt">
<input <input
type="checkbox" type="checkbox"
checked={formData.countsToPlan && !formData.isComment} checked={formData.countsToPlan && !formData.isComment}
@@ -219,13 +228,10 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
/> />
Vom Abo-Stundenkonto abziehen Vom Abo-Stundenkonto abziehen
</label> </label>
</div>
)} )}
</div>
<div className="col col-6"> <div className="field-row">
<div className="form-group"> <Field label="Beginn" htmlFor="worksheet-start-date">
<label className="form-label" htmlFor="worksheet-start-date">Beginn</label>
<DateTimeInput <DateTimeInput
id="worksheet-start-date" id="worksheet-start-date"
timeLabel="Uhrzeit Beginn" timeLabel="Uhrzeit Beginn"
@@ -235,10 +241,9 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
onDateChange={handleStartDateChange} onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)} onTimeChange={(value) => handleChange('startTime', value)}
/> />
</div> </Field>
<div className="form-group"> <Field label="Ende" htmlFor="worksheet-end-date">
<label className="form-label" htmlFor="worksheet-end-date">Ende</label>
<DateTimeInput <DateTimeInput
id="worksheet-end-date" id="worksheet-end-date"
timeLabel="Uhrzeit Ende" timeLabel="Uhrzeit Ende"
@@ -249,24 +254,23 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
onDateChange={(value) => handleChange('endDate', value)} onDateChange={(value) => handleChange('endDate', value)}
onTimeChange={(value) => handleChange('endTime', value)} onTimeChange={(value) => handleChange('endTime', value)}
/> />
</Field>
</div> </div>
<div className="form-group"> <Field
<label className="form-label" htmlFor="worksheet-total">Arbeitszeit</label> label="Arbeitszeit"
htmlFor="worksheet-total"
hint={autoCalculate && formData.startTime && formData.endTime
? 'Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: autoCalculate ? 'Beginn und Ende eintragen oder Zeit wählen' : 'Von Hand gewählt'}
>
<DurationSelect <DurationSelect
id="worksheet-total" id="worksheet-total"
value={formData.isComment ? 0 : formData.totalTime} value={formData.isComment ? 0 : formData.totalTime}
onChange={(value) => handleChange('totalTime', value)} onChange={(value) => handleChange('totalTime', value)}
disabled={formData.isComment} disabled={formData.isComment}
/> />
<small style={{ color: 'var(--text-2)', fontSize: '12px' }}> </Field>
{autoCalculate && formData.startTime && formData.endTime
? '✓ Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: autoCalculate ? 'Beginn und Ende eintragen oder Zeit wählen' : 'Von Hand gewählt'}
</small>
</div>
</div>
</div>
{!formData.isComment && ( {!formData.isComment && (
<SupportersEditor <SupportersEditor
@@ -274,33 +278,21 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
onChange={(list) => handleChange('supporters', list)} onChange={(list) => handleChange('supporters', list)}
excludeUserId={user?.$id} excludeUserId={user?.$id}
minutes={formData.totalTime} minutes={formData.totalTime}
employeesState={employeesState}
/> />
)} )}
<div className="form-group"> <Field label="Was wurde gemacht?" htmlFor="worksheet-details">
<label className="form-label">Was wurde gemacht?</label>
<textarea <textarea
className="form-control" id="worksheet-details"
className="textarea"
rows={5} rows={5}
placeholder="Beschreibe die durchgeführten Arbeiten..." placeholder="Beschreibe die durchgeführten Arbeiten..."
value={formData.details} value={formData.details}
onChange={(e) => handleChange('details', e.target.value)} onChange={(e) => handleChange('details', e.target.value)}
required required
/> />
</div> </Field>
</Dialog>
<div className="text-center mt-2">
<button
type="submit"
className="btn btn-dark"
disabled={loading}
>
{loading ? 'Speichert …' : 'Speichern'}
</button>
</div>
</form>
</div>
</div>
) )
} }
+42 -33
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 && ( {error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}> <div className="msg err" role="alert" ref={errorRef}>
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit}> <p className="muted">
<div className="form-group"> WSID {worksheet.wsid} · {worksheet.employeeName}
<label className="form-label" htmlFor="own-support-task">Wobei?</label> <br />Dir werden {stepLabel(supportMinutes(worksheet.totalTime))} angerechnet.
</p>
<Field label="Wobei?" htmlFor="own-support-task">
<input <input
id="own-support-task" id="own-support-task"
className="form-control" ref={taskRef}
className="input"
maxLength={200} maxLength={200}
required required
value={task} value={task}
onChange={(e) => setTask(e.target.value)} onChange={(e) => setTask(e.target.value)}
autoFocus
/> />
</div> </Field>
<div className="text-center mt-2 flex gap-2" style={{ justifyContent: 'center' }}> </Dialog>
<button type="submit" className="btn btn-dark" disabled={saving}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
{existing && (
<button type="button" className="btn" disabled={saving} onClick={() => save(null)}>
Eintrag entfernen
</button>
)}
</div>
</form>
</div>
</div>
) )
} }
+22 -16
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)}>
Entfernen
</button> </button>
</div> </div>
</div>
))} ))}
<button type="button" className="btn btn-sm" onClick={() => onChange([...value, { employeeId: '', name: '', task: '' }])}> <div>
<button type="button" className="link" onClick={() => onChange([...value, { employeeId: '', name: '', task: '' }])}>
+ weitere Person + weitere Person
</button> </button>
</div> </div>
</>
)} )}
</div> </div>
) )
+45 -41
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,28 +90,35 @@ 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 && ( {error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}> <div className="msg err" role="alert" ref={errorRef}>
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit}> <p className="muted">
<div className="form-group"> WSID {worksheet.wsid}
<label className="form-label" htmlFor="worksheet-times-start">Beginn</label> {isGit && <><br />Das Ende ist der Zeitpunkt des Git-Pushs. Trag ein, wann die Arbeit begonnen hat.</>}
</p>
<Field label="Beginn" htmlFor="worksheet-times-start">
<DateTimeInput <DateTimeInput
id="worksheet-times-start" id="worksheet-times-start"
timeLabel="Uhrzeit Beginn" timeLabel="Uhrzeit Beginn"
@@ -117,10 +129,9 @@ export default function WorksheetTimesModal({ worksheet, currentUserId, onClose,
onDateChange={handleStartDateChange} onDateChange={handleStartDateChange}
onTimeChange={(value) => handleChange('startTime', value)} onTimeChange={(value) => handleChange('startTime', value)}
/> />
</div> </Field>
<div className="form-group"> <Field label="Ende" htmlFor="worksheet-times-end">
<label className="form-label" htmlFor="worksheet-times-end">Ende</label>
<DateTimeInput <DateTimeInput
id="worksheet-times-end" id="worksheet-times-end"
timeLabel="Uhrzeit Ende" timeLabel="Uhrzeit Ende"
@@ -131,36 +142,29 @@ export default function WorksheetTimesModal({ worksheet, currentUserId, onClose,
onDateChange={(value) => handleChange('endDate', value)} onDateChange={(value) => handleChange('endDate', value)}
onTimeChange={(value) => handleChange('endTime', value)} onTimeChange={(value) => handleChange('endTime', value)}
/> />
</div> </Field>
<div className="form-group"> <Field
<label className="form-label" htmlFor="worksheet-times-total">Arbeitszeit</label> label="Arbeitszeit"
htmlFor="worksheet-times-total"
hint={manualTotal === null && calculatedTotal !== null
? 'Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: 'Von Hand gewählt'}
>
<DurationSelect <DurationSelect
id="worksheet-times-total" id="worksheet-times-total"
value={totalTime} value={totalTime}
onChange={(value) => setManualTotal(value)} onChange={(value) => setManualTotal(value)}
/> />
<small style={{ color: 'var(--text-2)', fontSize: '12px' }}> </Field>
{manualTotal === null && calculatedTotal !== null
? '✓ Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: 'Von Hand gewählt'}
</small>
</div>
<SupportersEditor <SupportersEditor
value={supporters} value={supporters}
onChange={setSupporters} onChange={setSupporters}
excludeUserId={currentUserId} excludeUserId={currentUserId}
minutes={totalTime} minutes={totalTime}
employeesState={employeesState}
/> />
</Dialog>
<div className="text-center mt-2">
<button type="submit" className="btn btn-dark" disabled={saving}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
</div>
</form>
</div>
</div>
) )
} }
+2
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; }