Arbeitsblatt-Formular erneuert: Kalender, deutsche Service-Art

- Start und Ende mit Kalender und Uhrzeitfeld (gemeinsame Komponente
  DateTimeInput, auch im Ticketformular); gespeichert weiter als
  dd.mm.yyyy / hhmm, das Ende rückt mit, wenn der Start später liegt
- Service Type nur noch "Fernwartung (remote)" oder "Vor Ort beim Kunden",
  vorbelegt vom Ticket; COMMENT entfällt (dafür gibt es "Nur Kommentar")
- Minuten werden aus den Uhrzeiten berechnet (über Mitternacht weiter)
- Minutenfeld nimmt ganze Minuten: mit step 15 ließ der Browser berechnete
  Zeiten wie 70 Minuten nicht absenden
- Arbeitsblatt-Liste und -Statistik zeigen die Service-Art auf Deutsch
  (Remote und altes Off Site zusammen als Fernwartung)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-26 19:59:07 +00:00
1 parent 8cb0bb8146
commit e678899141
7 files changed
+150 -164

No files matched your search

+18 -45
View File
@@ -11,7 +11,7 @@ import {
buildRequesterSuggestions, buildRequesterSuggestions,
rankSuggestions, rankSuggestions,
} from '../lib/ticketForm' } from '../lib/ticketForm'
import { AutocompleteInput } from './ui' import { AutocompleteInput, DateTimeInput } 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.
@@ -45,15 +45,6 @@ const emptyForm = () => ({
details: '' details: ''
}) })
// Klick irgendwo ins Datumsfeld oeffnet den Kalender, nicht nur das kleine Symbol
const openCalendar = (e) => {
try {
e.currentTarget.showPicker?.()
} catch {
// aeltere Browser: Kalender bleibt ueber das Symbol erreichbar
}
}
export default function CreateTicketModal({ isOpen, onClose, onCreate, customers = [] }) { export default function CreateTicketModal({ isOpen, onClose, onCreate, customers = [] }) {
const { config } = useAdminConfig() const { config } = useAdminConfig()
const { employees } = useEmployees() const { employees } = useEmployees()
@@ -260,45 +251,27 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="ticket-start-date">Start Date & Time</label> <label className="form-label" htmlFor="ticket-start-date">Start Date & Time</label>
<div className="datetime-pair"> <DateTimeInput
<input id="ticket-start-date"
id="ticket-start-date" timeLabel="Start time"
type="date" date={formData.startDate}
className="form-control" time={formData.startTime}
value={formData.startDate} onDateChange={handleStartDateChange}
onChange={(e) => handleStartDateChange(e.target.value)} onTimeChange={(value) => handleChange('startTime', value)}
onClick={openCalendar} />
/>
<input
type="time"
className="form-control"
aria-label="Start time"
value={formData.startTime}
onChange={(e) => handleChange('startTime', e.target.value)}
/>
</div>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="ticket-end-date">End Date & Time (Deadline)</label> <label className="form-label" htmlFor="ticket-end-date">End Date & Time (Deadline)</label>
<div className="datetime-pair"> <DateTimeInput
<input id="ticket-end-date"
id="ticket-end-date" timeLabel="End time"
type="date" min={formData.startDate}
className="form-control" date={formData.deadline}
min={formData.startDate || undefined} time={formData.endTime}
value={formData.deadline} onDateChange={(value) => handleChange('deadline', value)}
onChange={(e) => handleChange('deadline', e.target.value)} onTimeChange={(value) => handleChange('endTime', value)}
onClick={openCalendar} />
/>
<input
type="time"
className="form-control"
aria-label="End time"
value={formData.endTime}
onChange={(e) => handleChange('endTime', e.target.value)}
/>
</div>
</div> </div>
<div className="form-group"> <div className="form-group">
+74 -112
View File
@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { FaTimes } from 'react-icons/fa' import { FaTimes } from 'react-icons/fa'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { SERVICE_TYPES, todayIso, isoToGermanDate, timeToWoms, minutesBetween } from '../lib/ticketForm'
const SERVICE_TYPES = ['Remote', 'On Site', 'Off Site', 'COMMENT'] import { DateTimeInput } from './ui'
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
'Open', 'Open',
@@ -20,30 +20,25 @@ const STATUS_OPTIONS = [
// Akquise-Tickets: Ergebnis des Kundenkontakts (statt technischer Status) // Akquise-Tickets: Ergebnis des Kundenkontakts (statt technischer Status)
const ACQUISITION_STATUS = ['In Kontakt', 'Zugesagt', 'Abgesagt'] const ACQUISITION_STATUS = ['In Kontakt', 'Zugesagt', 'Abgesagt']
// Datum als "yyyy-mm-dd", Uhrzeit als "hh:mm" (Werte der Kalender-Felder).
// Die Service-Art kommt vom Ticket; das alte "Off Site" zaehlt als Fernwartung.
const initialForm = (workorder, newStatus) => ({
serviceType: SERVICE_TYPES.some(t => t.value === workorder?.serviceType)
? workorder.serviceType
: SERVICE_TYPES[0].value,
newStatus,
totalTime: 0,
startDate: todayIso(),
startTime: '',
endDate: todayIso(),
endTime: '',
details: '',
isComment: false,
countsToPlan: false
})
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate }) { export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate }) {
const { user } = useAuth() const { user } = useAuth()
const today = new Date().toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
const [formData, setFormData] = useState({
serviceType: 'Remote',
newStatus: workorder?.status || 'Open',
totalTime: 0,
startDate: today,
startTime: '',
endDate: today,
endTime: '',
details: '',
isComment: false,
countsToPlan: false
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [autoCalculate, setAutoCalculate] = useState(true)
const isAcquisition = workorder?.type === 'Akquise' const isAcquisition = workorder?.type === 'Akquise'
const statusOptions = isAcquisition ? ACQUISITION_STATUS : STATUS_OPTIONS const statusOptions = isAcquisition ? ACQUISITION_STATUS : STATUS_OPTIONS
@@ -51,49 +46,27 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
? (ACQUISITION_STATUS.includes(workorder?.status) ? workorder.status : 'In Kontakt') ? (ACQUISITION_STATUS.includes(workorder?.status) ? workorder.status : 'In Kontakt')
: (workorder?.status || 'Open') : (workorder?.status || 'Open')
const [formData, setFormData] = useState(() => initialForm(workorder, defaultStatus))
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [autoCalculate, setAutoCalculate] = useState(true)
// Reset form wenn Modal geöffnet wird // Reset form wenn Modal geöffnet wird
useEffect(() => { useEffect(() => {
if (isOpen && workorder) { if (isOpen && workorder) {
setFormData({ setFormData(initialForm(workorder, defaultStatus))
serviceType: 'Remote',
newStatus: defaultStatus,
totalTime: 0,
startDate: today,
startTime: '',
endDate: today,
endTime: '',
details: '',
isComment: false,
countsToPlan: false
})
setError('') setError('')
setAutoCalculate(true) setAutoCalculate(true)
} }
}, [isOpen, workorder, today]) }, [isOpen, workorder, defaultStatus])
// Automatische Zeitberechnung // Automatische Zeitberechnung
useEffect(() => { useEffect(() => {
if (autoCalculate && formData.startTime && formData.endTime && !formData.isComment) { if (!autoCalculate || formData.isComment) return
try { const minutes = minutesBetween(formData.startTime, formData.endTime)
const startHour = parseInt(formData.startTime.substring(0, 2)) if (minutes !== null) {
const startMin = parseInt(formData.startTime.substring(2, 4)) setFormData(prev => ({ ...prev, totalTime: minutes }))
const endHour = parseInt(formData.endTime.substring(0, 2))
const endMin = parseInt(formData.endTime.substring(2, 4))
if (!isNaN(startHour) && !isNaN(startMin) && !isNaN(endHour) && !isNaN(endMin)) {
const startTotal = startHour * 60 + startMin
const endTotal = endHour * 60 + endMin
let diff = endTotal - startTotal
if (diff < 0) {
diff += 24 * 60 // Overnight
}
setFormData(prev => ({ ...prev, totalTime: diff }))
}
} catch (err) {
// Ignoriere Fehler
}
} }
}, [formData.startTime, formData.endTime, formData.isComment, autoCalculate]) }, [formData.startTime, formData.endTime, formData.isComment, autoCalculate])
@@ -106,6 +79,15 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
} }
} }
// Das Ende darf nicht vor dem Start liegen, deshalb rueckt es mit
const handleStartDateChange = (value) => {
setFormData(prev => ({
...prev,
startDate: value,
endDate: value && prev.endDate && prev.endDate < value ? value : prev.endDate
}))
}
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
@@ -125,10 +107,10 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
oldStatus: workorder.status, oldStatus: workorder.status,
newStatus: formData.newStatus, newStatus: formData.newStatus,
totalTime: formData.isComment ? 0 : parseInt(formData.totalTime) || 0, totalTime: formData.isComment ? 0 : parseInt(formData.totalTime) || 0,
startDate: formData.startDate, startDate: isoToGermanDate(formData.startDate),
startTime: formData.startTime, startTime: timeToWoms(formData.startTime),
endDate: formData.endDate, endDate: isoToGermanDate(formData.endDate),
endTime: formData.endTime, endTime: timeToWoms(formData.endTime),
details: formData.details, details: formData.details,
isComment: formData.isComment, isComment: formData.isComment,
countsToPlan: !formData.isComment && formData.countsToPlan, countsToPlan: !formData.isComment && formData.countsToPlan,
@@ -178,7 +160,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
required required
> >
{SERVICE_TYPES.map(type => ( {SERVICE_TYPES.map(type => (
<option key={type} value={type}>{type}</option> <option key={type.value} value={type.value}>{type.label}</option>
))} ))}
</select> </select>
</div> </div>
@@ -227,13 +209,41 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
</div> </div>
<div className="col col-6"> <div className="col col-6">
<div className="form-group">
<label className="form-label" htmlFor="worksheet-start-date">Start Date & Time</label>
<DateTimeInput
id="worksheet-start-date"
timeLabel="Start time"
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">End Date & Time</label>
<DateTimeInput
id="worksheet-end-date"
timeLabel="End time"
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"> <div className="form-group">
<label className="form-label">Total Time (Minuten)</label> <label className="form-label">Total Time (Minuten)</label>
{/* Ganze Minuten: mit step 15 blockierte der Browser berechnete Zeiten wie 70 */}
<input <input
type="number" type="number"
className="form-control" className="form-control"
min="0" min="0"
step="15" step="1"
value={formData.totalTime} value={formData.totalTime}
onChange={(e) => handleChange('totalTime', e.target.value)} onChange={(e) => handleChange('totalTime', e.target.value)}
disabled={formData.isComment} disabled={formData.isComment}
@@ -245,54 +255,6 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
: 'Manuell eingeben'} : 'Manuell eingeben'}
</small> </small>
</div> </div>
<div className="form-group">
<label className="form-label">Start Date</label>
<input
type="text"
className="form-control"
placeholder="dd.mm.yyyy"
value={formData.startDate}
onChange={(e) => handleChange('startDate', e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label">End Date</label>
<input
type="text"
className="form-control"
placeholder="dd.mm.yyyy"
value={formData.endDate}
onChange={(e) => handleChange('endDate', e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label">Start Time</label>
<input
type="text"
className="form-control"
placeholder="hhmm"
value={formData.startTime}
onChange={(e) => handleChange('startTime', e.target.value)}
maxLength="4"
/>
</div>
<div className="form-group">
<label className="form-label">End Time</label>
<input
type="text"
className="form-control"
placeholder="hhmm"
value={formData.endTime}
onChange={(e) => handleChange('endTime', e.target.value)}
maxLength="4"
/>
</div>
</div> </div>
</div> </div>
+3 -2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp } from 'react-icons/fa' import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp } from 'react-icons/fa'
import { serviceTypeLabel } from '../lib/ticketForm'
function needsTimeEntry(ws) { function needsTimeEntry(ws) {
return ws.serviceType === 'GIT' && (!ws.startTime || !Number(ws.totalTime)) return ws.serviceType === 'GIT' && (!ws.startTime || !Number(ws.totalTime))
@@ -147,7 +148,7 @@ export default function WorksheetList({ worksheets, totalTime, loading, onEditTi
<span className="badge" style={{ <span className="badge" style={{
background: 'rgba(255,255,255,0.2)', background: 'rgba(255,255,255,0.2)',
fontSize: '0.8rem' fontSize: '0.8rem'
}}>{ws.serviceType}</span> }}>{serviceTypeLabel(ws.serviceType)}</span>
</div> </div>
)} )}
</div> </div>
@@ -237,7 +238,7 @@ export default function WorksheetList({ worksheets, totalTime, loading, onEditTi
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)', background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white', color: 'white',
fontSize: '0.85rem' fontSize: '0.85rem'
}}>{ws.serviceType}</span> }}>{serviceTypeLabel(ws.serviceType)}</span>
</div> </div>
{/* Status-Änderung */} {/* Status-Änderung */}
+3 -2
View File
@@ -1,4 +1,5 @@
import { FaClock, FaUsers, FaChartLine } from 'react-icons/fa' import { FaClock, FaUsers, FaChartLine } from 'react-icons/fa'
import { serviceTypeLabel } from '../lib/ticketForm'
export default function WorksheetStats({ worksheets }) { export default function WorksheetStats({ worksheets }) {
if (!worksheets || worksheets.length === 0) { if (!worksheets || worksheets.length === 0) {
@@ -29,9 +30,9 @@ export default function WorksheetStats({ worksheets }) {
}, {}) }, {})
// Service Type Verteilung // Service Type Verteilung (Remote und das alte Off Site zusammen als Fernwartung)
const byServiceType = worksheets.reduce((acc, ws) => { const byServiceType = worksheets.reduce((acc, ws) => {
const type = ws.serviceType || 'Unknown' const type = serviceTypeLabel(ws.serviceType) || 'Unknown'
acc[type] = (acc[type] || 0) + 1 acc[type] = (acc[type] || 0) + 1
return acc return acc
}, {}) }, {})
+34
View File
@@ -0,0 +1,34 @@
// Klick irgendwo ins Datumsfeld oeffnet den Kalender, nicht nur das kleine Symbol
const openCalendar = (e) => {
try {
e.currentTarget.showPicker?.()
} catch {
// aeltere Browser: Kalender bleibt ueber das Symbol erreichbar
}
}
/* Datum mit Kalender und Uhrzeit nebeneinander. Werte wie die Browser-Felder:
Datum "yyyy-mm-dd", Uhrzeit "hh:mm" - umgerechnet wird erst beim Speichern. */
export default function DateTimeInput({ id, date, time, onDateChange, onTimeChange, min, required, timeLabel }) {
return (
<div className="datetime-pair">
<input
id={id}
type="date"
className="form-control"
min={min || undefined}
required={required}
value={date}
onChange={(e) => onDateChange(e.target.value)}
onClick={openCalendar}
/>
<input
type="time"
className="form-control"
aria-label={timeLabel}
value={time}
onChange={(e) => onTimeChange(e.target.value)}
/>
</div>
)
}
+1
View File
@@ -8,3 +8,4 @@ export { default as EmptyState } from './EmptyState'
export { default as Field } from './Field' export { default as Field } from './Field'
export { default as Kpi } from './Kpi' export { default as Kpi } from './Kpi'
export { default as AutocompleteInput } from './AutocompleteInput' export { default as AutocompleteInput } from './AutocompleteInput'
export { default as DateTimeInput } from './DateTimeInput'
+17 -3
View File
@@ -1,4 +1,4 @@
/* Hilfen fuer "Neues Ticket" und die Anzeige der Tickets. /* Hilfen fuer die Formulare "Neues Ticket" und "Arbeitsblatt" und die Anzeige.
WOMS speichert Datum als Text "dd.mm.yyyy" und Uhrzeit als "hhmm" WOMS speichert Datum als Text "dd.mm.yyyy" und Uhrzeit als "hhmm"
(siehe planboard.js). Die Kalender-Felder des Browsers liefern dagegen (siehe planboard.js). Die Kalender-Felder des Browsers liefern dagegen
"yyyy-mm-dd" bzw. "hh:mm" - umgerechnet wird erst beim Speichern. */ "yyyy-mm-dd" bzw. "hh:mm" - umgerechnet wird erst beim Speichern. */
@@ -9,8 +9,13 @@ export const SERVICE_TYPES = [
{ value: 'On Site', label: 'Vor Ort beim Kunden' }, { value: 'On Site', label: 'Vor Ort beim Kunden' },
] ]
// Kurzform fuer Badges. Das alte "Off Site" meinte ebenfalls Arbeit aus der Ferne. // Kurzform fuer Badges und Listen. Das alte "Off Site" meinte ebenfalls Arbeit aus der Ferne.
const SERVICE_TYPE_SHORT = { remote: 'Fernwartung', 'off site': 'Fernwartung', 'on site': 'Vor Ort' } const SERVICE_TYPE_SHORT = {
remote: 'Fernwartung',
'off site': 'Fernwartung',
'on site': 'Vor Ort',
comment: 'Kommentar',
}
export function serviceTypeLabel(value) { export function serviceTypeLabel(value) {
return SERVICE_TYPE_SHORT[String(value || '').trim().toLowerCase()] || value return SERVICE_TYPE_SHORT[String(value || '').trim().toLowerCase()] || value
@@ -35,6 +40,15 @@ export function timeToWoms(value) {
return m ? `${m[1].padStart(2, '0')}${m[2]}` : '' return m ? `${m[1].padStart(2, '0')}${m[2]}` : ''
} }
// Minuten von "hh:mm" bis "hh:mm"; liegt das Ende davor, geht es ueber Mitternacht
export function minutesBetween(start, end) {
const a = /^(\d{1,2}):(\d{2})/.exec(String(start || ''))
const b = /^(\d{1,2}):(\d{2})/.exec(String(end || ''))
if (!a || !b) return null
const diff = Number(b[1]) * 60 + Number(b[2]) - (Number(a[1]) * 60 + Number(a[2]))
return diff < 0 ? diff + 24 * 60 : diff
}
/* Vorschlaege fuer "Requested by" / "Requested for": Kunden, Mitarbeiter /* Vorschlaege fuer "Requested by" / "Requested for": Kunden, Mitarbeiter
und Namen aus frueheren Tickets. Pro Name (ohne Gross-/Kleinschreibung) und Namen aus frueheren Tickets. Pro Name (ohne Gross-/Kleinschreibung)
ein Eintrag; customerIds merkt sich, zu welchen Kunden er gehoert. */ ein Eintrag; customerIds merkt sich, zu welchen Kunden er gehoert. */