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:
1 parent
cf1d653eb2
commit
d86f9c07cd
7 files changed
+356
-163
No files matched your search
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -271,6 +271,8 @@ export function useWorkorders(filters = {}) {
|
||||
responseLevel: data.responseLevel || '',
|
||||
serviceType: data.serviceType || 'Remote',
|
||||
customerId: data.customerId || '',
|
||||
customerName: data.customerName || '',
|
||||
customerLocation: data.customerLocation || '',
|
||||
assignedTo: automatic.assignedTo,
|
||||
assignedName: automatic.assignedName,
|
||||
requestedBy: data.requestedBy || '',
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/* Hilfen fuer "Neues Ticket".
|
||||
WOMS speichert Datum als Text "dd.mm.yyyy" und Uhrzeit als "hhmm"
|
||||
(siehe planboard.js). Die Kalender-Felder des Browsers liefern dagegen
|
||||
"yyyy-mm-dd" bzw. "hh:mm" - umgerechnet wird erst beim Speichern. */
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
|
||||
// Heutiges Datum in Ortszeit als "yyyy-mm-dd" (Wert fuer <input type="date">)
|
||||
export function todayIso(now = new Date()) {
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
}
|
||||
|
||||
// "2026-09-26" -> "26.09.2026"
|
||||
export function isoToGermanDate(iso) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''))
|
||||
return m ? `${m[3]}.${m[2]}.${m[1]}` : ''
|
||||
}
|
||||
|
||||
// "09:30" -> "0930"
|
||||
export function timeToWoms(value) {
|
||||
const m = /^(\d{1,2}):(\d{2})/.exec(String(value || ''))
|
||||
return m ? `${m[1].padStart(2, '0')}${m[2]}` : ''
|
||||
}
|
||||
|
||||
/* Vorschlaege fuer "Requested by" / "Requested for": Kunden, Mitarbeiter
|
||||
und Namen aus frueheren Tickets. Pro Name (ohne Gross-/Kleinschreibung)
|
||||
ein Eintrag; customerIds merkt sich, zu welchen Kunden er gehoert. */
|
||||
export function buildRequesterSuggestions({ customers = [], employees = [], tickets = [] }) {
|
||||
const byKey = new Map()
|
||||
const add = (value, { hint = '', search = '', customerId = '', used = false } = {}) => {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return
|
||||
const key = text.toLowerCase()
|
||||
let entry = byKey.get(key)
|
||||
if (!entry) {
|
||||
entry = { value: text, hint: '', search: key, customerIds: new Set(), uses: 0 }
|
||||
byKey.set(key, entry)
|
||||
}
|
||||
if (hint && !entry.hint) entry.hint = hint
|
||||
if (search) entry.search += ` ${search.toLowerCase()}`
|
||||
if (customerId) entry.customerIds.add(customerId)
|
||||
if (used) entry.uses += 1
|
||||
}
|
||||
|
||||
for (const customer of customers) {
|
||||
add(customer.name || customer.companyName, {
|
||||
hint: customer.email ? `Kunde · ${customer.email}` : 'Kunde',
|
||||
search: customer.email || '',
|
||||
customerId: customer.$id,
|
||||
})
|
||||
}
|
||||
for (const employee of employees) {
|
||||
add(employee.displayName, { hint: 'Mitarbeiter', search: employee.email || '' })
|
||||
}
|
||||
for (const ticket of tickets) {
|
||||
add(ticket.requestedBy, { customerId: ticket.customerId, used: true })
|
||||
add(ticket.requestedFor, { customerId: ticket.customerId, used: true })
|
||||
}
|
||||
for (const entry of byKey.values()) {
|
||||
if (!entry.hint) entry.hint = 'aus früheren Tickets'
|
||||
}
|
||||
return [...byKey.values()]
|
||||
}
|
||||
|
||||
/* Passende Vorschlaege zur Eingabe, beste zuerst. Namen des gewaehlten
|
||||
Kunden stehen oben; ohne Eingabe gibt es nur diese und schon benutzte
|
||||
Namen, damit die Liste nicht einfach alle Kunden aufzaehlt. */
|
||||
export function rankSuggestions(entries, query, { customerId = '', limit = 8 } = {}) {
|
||||
const q = String(query || '').trim().toLowerCase()
|
||||
const ranked = []
|
||||
for (const entry of entries) {
|
||||
const value = entry.value.toLowerCase()
|
||||
if (q && value === q) continue
|
||||
const forCustomer = Boolean(customerId) && entry.customerIds.has(customerId)
|
||||
let score
|
||||
if (!q) {
|
||||
if (!forCustomer && !entry.uses) continue
|
||||
score = 0
|
||||
} else if (value.startsWith(q)) {
|
||||
score = 3
|
||||
} else if (value.split(/[\s.@-]+/).some((word) => word.startsWith(q))) {
|
||||
score = 2
|
||||
} else if (entry.search.includes(q)) {
|
||||
score = 1
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
if (forCustomer) score += 2
|
||||
score += Math.min(entry.uses, 10) / 10
|
||||
ranked.push({ entry, score })
|
||||
}
|
||||
ranked.sort((a, b) => b.score - a.score || a.entry.value.localeCompare(b.entry.value, 'de'))
|
||||
return ranked.slice(0, limit).map(({ entry }) => entry)
|
||||
}
|
||||
@@ -104,6 +104,7 @@ export default function TicketsPage() {
|
||||
<input type="date" className="form-control" value={localFilters.createdDate} onChange={(e) => setLocalFilters({ ...localFilters, createdDate: e.target.value })} />
|
||||
<select className="form-control" value={localFilters.type} onChange={(e) => setLocalFilters({ ...localFilters, type: e.target.value })}>
|
||||
<option value="">Typ / Ort</option>
|
||||
<option>Webpage</option><option>Migration</option>
|
||||
<option>Supportrequest</option><option>Change Request</option><option>Maintenance</option>
|
||||
<option>Project</option><option>Procurement</option><option>Emergency Call</option>
|
||||
<option>Home Office</option><option>Holidays</option><option>Trip</option>
|
||||
|
||||
@@ -1129,3 +1129,50 @@ textarea.form-control {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Neues Ticket: Datum + Uhrzeit nebeneinander; dunkle Kalender statt weisser */
|
||||
.datetime-pair { display: flex; gap: 8px; }
|
||||
.datetime-pair .form-control { flex: 1; min-width: 0; color-scheme: dark; }
|
||||
.datetime-pair input[type="date"] { cursor: pointer; }
|
||||
|
||||
/* Autovervollstaendigung (Requested by / Requested for) */
|
||||
.autocomplete { position: relative; }
|
||||
.autocomplete-list {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
box-shadow: var(--shadow-3);
|
||||
}
|
||||
.autocomplete-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.autocomplete-item.active { background: var(--accent-soft); }
|
||||
.autocomplete-item.active .autocomplete-value { color: var(--accent); }
|
||||
.autocomplete-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.autocomplete-hint {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
Reference in new issue
Block a user