Neues Ticket: Formular entschlackt, Kalender und Autovervollständigung

- Work Order Type nur noch Webpage und neu Migration (Kunde kommt von anderem Anbieter)
- Affected System, Response Level und Benachrichtigungsfelder entfernt
- Service Type: Fernwartung (remote) oder Vor Ort beim Kunden, Standard Fernwartung;
  gespeichert werden weiter "Remote" / "On Site"
- Start und Ende mit Kalender und Uhrzeitfeld; gespeichert weiter als dd.mm.yyyy / hhmm
- Requested by/for schlagen Kunden, Mitarbeiter und Namen aus den letzten Tickets vor,
  die des gewählten Kunden zuerst
- Neue Tickets speichern Kundenname und Ort (die Karte zeigte sonst "Unbekannt")
- Ticketfilter kennt Webpage und Migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-26 19:30:29 +00:00
1 parent cf1d653eb2
commit d86f9c07cd
7 files changed
+356 -163

No files matched your search

+124 -163
View File
@@ -1,30 +1,30 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useAdminConfig } from '../hooks/useAdminConfig'
import { useEmployees } from '../hooks/useEmployees'
import { databases, DATABASE_ID, COLLECTIONS, Query, isDemoMode } from '../lib/appwrite'
import {
todayIso,
isoToGermanDate,
timeToWoms,
buildRequesterSuggestions,
rankSuggestions,
} from '../lib/ticketForm'
import { AutocompleteInput } from './ui'
// Fallback-Werte falls Config nicht geladen werden kann
const DEFAULT_TICKET_TYPES = [
'Akquise', 'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
'Maintenance', 'Project', 'Webpage', 'Controlling', 'Development', 'Documentation',
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
'Rollout', 'Emergency Call', 'Other Services'
// Neue Tickets gibt es aktuell nur fuer diese Arten; alte Tickets behalten ihre Art.
// Migration = ein Kunde, den wir von einem anderen Anbieter uebernehmen.
const TICKET_TYPES = [
{ value: 'Webpage', label: 'Webpage' },
{ value: 'Migration', label: 'Migration (Übernahme von anderem Anbieter)' },
]
const DEFAULT_SYSTEMS = [
'Account View', 'Client', 'Cofano', 'Credentials', 'Diamant', 'Docuware',
'EDI', 'eMail', 'Employee', 'Invoice', 'LBase', 'Medical Office', 'Network',
'O365', 'PDF Viewer', 'Printer', 'Reports', 'Server', 'Time Tracking',
'TK', 'TOS', 'Vivendi NG', 'VGM', '(W)LAN', '(W)WAN', 'WOMS', 'n/a'
// Gespeichert werden die bisherigen Werte, angezeigt wird Deutsch
const SERVICE_TYPES = [
{ value: 'Remote', label: 'Fernwartung (remote)' },
{ value: 'On Site', label: 'Vor Ort beim Kunden' },
]
const DEFAULT_RESPONSE_LEVELS = [
'USER', 'KEY USER', 'Helpdesk', 'Support', 'Admin', 'FS/FE', '24/7',
'TECH MGMT', 'Backoffice', 'BUSI MGMT', 'n/a'
]
const DEFAULT_SERVICE_TYPES = ['Remote', 'On Site', 'Off Site']
const DEFAULT_PRIORITIES = [
{ value: 0, label: 'None' },
{ value: 1, label: 'Low' },
@@ -33,56 +33,68 @@ const DEFAULT_PRIORITIES = [
{ value: 4, label: 'Critical' }
]
const today = new Date().toLocaleDateString('de-DE')
// Datum als "yyyy-mm-dd", Uhrzeit als "hh:mm" (Werte der Kalender-Felder)
const emptyForm = () => ({
customerId: '',
type: TICKET_TYPES[0].value,
serviceType: SERVICE_TYPES[0].value,
priority: 1,
topic: '',
requestedBy: '',
requestedFor: '',
startDate: todayIso(),
startTime: '',
deadline: todayIso(),
endTime: '',
estimate: '30',
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 = [] }) {
const { config } = useAdminConfig()
const { employees } = useEmployees()
// Verwende Config-Werte oder Fallbacks
const TICKET_TYPES = config?.ticketTypes || DEFAULT_TICKET_TYPES
const SYSTEMS = config?.systems || DEFAULT_SYSTEMS
const RESPONSE_LEVELS = config?.responseLevels || DEFAULT_RESPONSE_LEVELS
const SERVICE_TYPES = config?.serviceTypes || DEFAULT_SERVICE_TYPES
const PRIORITIES = config?.priorities || DEFAULT_PRIORITIES
const [formData, setFormData] = useState({
customerId: '',
type: '',
systemType: '',
responseLevel: '',
serviceType: '',
priority: 1,
topic: '',
requestedBy: '',
requestedFor: '',
startDate: today,
startTime: '',
deadline: today,
endTime: '',
estimate: '30',
mailCopyTo: '',
sendNotification: false,
details: ''
})
const [formData, setFormData] = useState(emptyForm)
const [recentTickets, setRecentTickets] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
// Setze Default-Werte wenn Config geladen ist oder Modal geöffnet wird
// Reset error when modal opens
useEffect(() => {
if (isOpen && (TICKET_TYPES.length > 0 || SERVICE_TYPES.length > 0)) {
setFormData(prev => ({
...prev,
type: prev.type || TICKET_TYPES[0] || 'Supportrequest',
serviceType: prev.serviceType || SERVICE_TYPES[0] || 'Remote',
priority: prev.priority || (PRIORITIES.find(p => p.value === 1)?.value || 1)
}))
if (isOpen) setError('')
}, [isOpen])
// Namen aus den letzten Tickets fuer die Autovervollstaendigung
useEffect(() => {
if (!isOpen || isDemoMode) return
let cancelled = false
databases
.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.orderDesc('$createdAt'), Query.limit(200)])
.then((response) => {
if (!cancelled) setRecentTickets(response.documents || [])
})
.catch((err) => console.warn('Vorschläge aus früheren Tickets nicht geladen:', err))
return () => {
cancelled = true
}
// Reset error when modal opens
if (isOpen) {
setError('')
}
}, [isOpen, TICKET_TYPES, SERVICE_TYPES, PRIORITIES])
}, [isOpen])
const suggestions = useMemo(
() => buildRequesterSuggestions({ customers, employees, tickets: recentTickets }),
[customers, employees, recentTickets]
)
const getSuggestions = (text) => rankSuggestions(suggestions, text, { customerId: formData.customerId })
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }))
@@ -90,34 +102,35 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
if (error) setError('')
}
// Das Ende darf nicht vor dem Start liegen, deshalb rueckt es mit
const handleStartDateChange = (value) => {
setFormData(prev => ({
...prev,
startDate: value,
deadline: value && prev.deadline && prev.deadline < value ? value : prev.deadline
}))
if (error) setError('')
}
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
const customer = customers.find(c => c.$id === formData.customerId)
try {
const result = await onCreate(formData)
const result = await onCreate({
...formData,
customerName: customer?.name || '',
customerLocation: customer?.location || '',
startDate: isoToGermanDate(formData.startDate),
startTime: timeToWoms(formData.startTime),
deadline: isoToGermanDate(formData.deadline),
endTime: timeToWoms(formData.endTime)
})
if (result.success) {
onClose()
setFormData({
customerId: '',
type: TICKET_TYPES[0] || 'Supportrequest',
systemType: '',
responseLevel: '',
serviceType: SERVICE_TYPES[0] || 'Remote',
priority: PRIORITIES.find(p => p.value === 1)?.value || 1,
topic: '',
requestedBy: '',
requestedFor: '',
startDate: today,
startTime: '',
deadline: today,
endTime: '',
estimate: '30',
mailCopyTo: '',
sendNotification: false,
details: ''
})
setFormData(emptyForm())
} else {
setError(result.error || 'Fehler beim Erstellen des Tickets')
}
@@ -138,13 +151,13 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
</span>
<div className="overlay-content">
<h2 className="mb-2">Create New Ticket</h2>
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="row">
<div className="col col-6">
@@ -171,36 +184,7 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
onChange={(e) => handleChange('type', e.target.value)}
>
{TICKET_TYPES.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Affected System</label>
<select
className="form-control"
value={formData.systemType}
onChange={(e) => handleChange('systemType', e.target.value)}
required
>
<option value="">Affected System</option>
{SYSTEMS.map(sys => (
<option key={sys} value={sys}>{sys}</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Response Level</label>
<select
className="form-control"
value={formData.responseLevel}
onChange={(e) => handleChange('responseLevel', e.target.value)}
>
<option value="">Response Level</option>
{RESPONSE_LEVELS.map(level => (
<option key={level} value={level}>{level}</option>
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
@@ -213,7 +197,7 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
onChange={(e) => handleChange('serviceType', e.target.value)}
>
{SERVICE_TYPES.map(type => (
<option key={type} value={type}>{type}</option>
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
@@ -257,68 +241,67 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
</div>
<div className="form-group">
<label className="form-label">Requested by</label>
<input
type="text"
className="form-control"
<label className="form-label" htmlFor="ticket-requested-by">Requested by</label>
<AutocompleteInput
id="ticket-requested-by"
placeholder="Name"
value={formData.requestedBy}
onChange={(e) => handleChange('requestedBy', e.target.value)}
onChange={(value) => handleChange('requestedBy', value)}
getSuggestions={getSuggestions}
required
/>
</div>
<div className="form-group">
<label className="form-label">Requested for</label>
<input
type="text"
className="form-control"
<label className="form-label" htmlFor="ticket-requested-for">Requested for</label>
<AutocompleteInput
id="ticket-requested-for"
placeholder="Name"
value={formData.requestedFor}
onChange={(e) => handleChange('requestedFor', e.target.value)}
onChange={(value) => handleChange('requestedFor', value)}
getSuggestions={getSuggestions}
/>
</div>
<div className="form-group">
<label className="form-label">Start Date & Time</label>
<div style={{ display: 'flex', gap: '8px' }}>
<label className="form-label" htmlFor="ticket-start-date">Start Date & Time</label>
<div className="datetime-pair">
<input
type="text"
id="ticket-start-date"
type="date"
className="form-control"
placeholder="dd.mm.yyyy"
value={formData.startDate}
onChange={(e) => handleChange('startDate', e.target.value)}
style={{ flex: 1 }}
onChange={(e) => handleStartDateChange(e.target.value)}
onClick={openCalendar}
/>
<input
type="text"
type="time"
className="form-control"
placeholder="hhmm"
aria-label="Start time"
value={formData.startTime}
onChange={(e) => handleChange('startTime', e.target.value)}
style={{ flex: 1 }}
/>
</div>
</div>
<div className="form-group">
<label className="form-label">End Date & Time (Deadline)</label>
<div style={{ display: 'flex', gap: '8px' }}>
<label className="form-label" htmlFor="ticket-end-date">End Date & Time (Deadline)</label>
<div className="datetime-pair">
<input
type="text"
id="ticket-end-date"
type="date"
className="form-control"
placeholder="dd.mm.yyyy"
min={formData.startDate || undefined}
value={formData.deadline}
onChange={(e) => handleChange('deadline', e.target.value)}
style={{ flex: 1 }}
onClick={openCalendar}
/>
<input
type="text"
type="time"
className="form-control"
placeholder="hhmm"
aria-label="End time"
value={formData.endTime}
onChange={(e) => handleChange('endTime', e.target.value)}
style={{ flex: 1 }}
/>
</div>
</div>
@@ -333,28 +316,6 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
onChange={(e) => handleChange('estimate', e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">Notification Recipient(s)</label>
<input
type="text"
className="form-control"
placeholder="name@domain.xx"
value={formData.mailCopyTo}
onChange={(e) => handleChange('mailCopyTo', e.target.value)}
/>
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={formData.sendNotification}
onChange={(e) => handleChange('sendNotification', e.target.checked)}
/>
Send Notification?
</label>
</div>
</div>
</div>
@@ -370,8 +331,8 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
</div>
<div className="text-center mt-2">
<button
type="submit"
<button
type="submit"
className="btn btn-dark"
disabled={loading}
>
+87
View File
@@ -0,0 +1,87 @@
import { useId, useState } from 'react'
/* Textfeld mit Vorschlagsliste. Freie Eingaben bleiben erlaubt;
getSuggestions(text) liefert [{ value, hint }]. */
export default function AutocompleteInput({ value, onChange, getSuggestions, id, placeholder, required }) {
const listId = useId()
const [open, setOpen] = useState(false)
const [active, setActive] = useState(-1)
const items = open ? getSuggestions(value) : []
const showList = items.length > 0
const choose = (item) => {
onChange(item.value)
setOpen(false)
setActive(-1)
}
const handleKeyDown = (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setOpen(true)
setActive((i) => Math.min(i + 1, items.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((i) => Math.max(i - 1, -1))
} else if (e.key === 'Enter' && showList && items[active]) {
e.preventDefault()
choose(items[active])
} else if (e.key === 'Escape' && showList) {
e.preventDefault()
setOpen(false)
}
}
return (
<div className="autocomplete">
<input
type="text"
id={id}
className="form-control"
role="combobox"
aria-autocomplete="list"
aria-expanded={showList}
aria-controls={listId}
aria-activedescendant={showList && items[active] ? `${listId}-${active}` : undefined}
autoComplete="off"
placeholder={placeholder}
required={required}
value={value}
onChange={(e) => {
onChange(e.target.value)
setOpen(true)
setActive(-1)
}}
onFocus={() => setOpen(true)}
onBlur={() => {
setOpen(false)
setActive(-1)
}}
onKeyDown={handleKeyDown}
/>
{showList && (
<ul className="autocomplete-list" id={listId} role="listbox">
{items.map((item, index) => (
<li
key={item.value}
id={`${listId}-${index}`}
role="option"
aria-selected={index === active}
className={`autocomplete-item${index === active ? ' active' : ''}`}
// mousedown statt click: sonst verliert das Feld vorher den Fokus und die Liste schliesst
onMouseDown={(e) => {
e.preventDefault()
choose(item)
}}
onMouseEnter={() => setActive(index)}
>
<span className="autocomplete-value">{item.value}</span>
{item.hint && <span className="autocomplete-hint">{item.hint}</span>}
</li>
))}
</ul>
)}
</div>
)
}
+1
View File
@@ -7,3 +7,4 @@ export { default as Tabs } from './Tabs'
export { default as EmptyState } from './EmptyState'
export { default as Field } from './Field'
export { default as Kpi } from './Kpi'
export { default as AutocompleteInput } from './AutocompleteInput'