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
+159 -173

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">
+83 -121
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,66 +46,53 @@ 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])
const handleChange = (field, value) => { const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value })) setFormData(prev => ({ ...prev, [field]: value }))
// Wenn totalTime manuell geändert wird, deaktiviere Auto-Berechnung // Wenn totalTime manuell geändert wird, deaktiviere Auto-Berechnung
if (field === 'totalTime') { if (field === 'totalTime') {
setAutoCalculate(false) setAutoCalculate(false)
} }
} }
// 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)
setError('') setError('')
try { try {
if (!formData.details.trim()) { if (!formData.details.trim()) {
setError('Bitte Details eingeben') setError('Bitte Details eingeben')
@@ -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,
@@ -136,7 +118,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
} }
const result = await onCreate(worksheetData, user) const result = await onCreate(worksheetData, user)
if (result.success) { if (result.success) {
onClose() onClose()
} else { } else {
@@ -159,13 +141,13 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
</span> </span>
<div className="overlay-content"> <div className="overlay-content">
<h2 className="mb-2">Create New Worksheet - WOID {workorder.woid}</h2> <h2 className="mb-2">Create New Worksheet - WOID {workorder.woid}</h2>
{error && ( {error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}> <div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="row"> <div className="row">
<div className="col col-6"> <div className="col col-6">
@@ -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,72 +209,52 @@ 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}
placeholder="0" placeholder="0"
/> />
<small style={{ color: '#a0aec0', fontSize: '12px' }}> <small style={{ color: '#a0aec0', fontSize: '12px' }}>
{autoCalculate && formData.startTime && formData.endTime {autoCalculate && formData.startTime && formData.endTime
? '✓ Automatisch berechnet' ? '✓ Automatisch berechnet'
: '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>
@@ -309,8 +271,8 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
</div> </div>
<div className="text-center mt-2"> <div className="text-center mt-2">
<button <button
type="submit" type="submit"
className="btn btn-dark" className="btn btn-dark"
disabled={loading} disabled={loading}
> >
+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. */