Tickets auf WEBklar zugeschnitten (Paket B)
- Status: 6 statt 10, auf Deutsch (Offen, In Arbeit, Wartet auf Kunde,
Prüfung, Erledigt, Storniert). Gespeichert werden die Werte, die das
Kundenportal für die Fortschrittsleiste kennt; alte Werte wie Assigned
werden weiter angezeigt ("Eingeplant"), sind aber nicht mehr wählbar.
- Zuweisen ändert den Status nicht mehr; neue Tickets starten "Offen".
- Priorität: Schalter "Dringend" statt fünf Stufen (dringend = 4, normal
= 2; alte Stufen Hoch/Kritisch gelten als dringend), rotes Abzeichen.
- Ticketliste: Standardansicht "Aufträge" ohne Akquise-Tickets; Filter
Typ (Aufträge/Webpage/Migration/Projekt/Akquise/Alle), 6 Status-Gruppen,
"Nur dringende". Die toten Felder Kunde, Benutzer/Thema und Datum sind
durch eine echte Suche ersetzt (Kunde, Betreff, Angefragt von, WOID).
Deep-Link ?woid= zeigt das Ticket weiter unabhängig von Art und Status.
- Formulare "Neues Ticket" und "Arbeitsblatt" auf Deutsch; raus sind
Workload Estimate, Requested for und die Service-Art am Ticket
(gespeichert wird weiter Fernwartung).
- Ticketkarte: Projekt/Preview im Details-Reiter (4 statt 5 Reiter), eine
Zusammenfassungszeile statt der großen Statistik, doppelter
Projekte-Stift weg, Service-Abzeichen weg.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bf007df737
commit
062d992b63
17 files changed
+359
-626
No files matched your search
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { FaTimes } from 'react-icons/fa'
|
import { FaTimes } from 'react-icons/fa'
|
||||||
import { useAdminConfig } from '../hooks/useAdminConfig'
|
|
||||||
import { useEmployees } from '../hooks/useEmployees'
|
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 {
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
buildRequesterSuggestions,
|
buildRequesterSuggestions,
|
||||||
rankSuggestions,
|
rankSuggestions,
|
||||||
} from '../lib/ticketForm'
|
} from '../lib/ticketForm'
|
||||||
|
import { priorityFor } from '../lib/ticketStatus'
|
||||||
import { AutocompleteInput, DateTimeInput } 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.
|
||||||
@@ -20,36 +20,25 @@ const TICKET_TYPES = [
|
|||||||
{ value: 'Migration', label: 'Migration (Übernahme von anderem Anbieter)' },
|
{ value: 'Migration', label: 'Migration (Übernahme von anderem Anbieter)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const DEFAULT_PRIORITIES = [
|
// Datum als "yyyy-mm-dd", Uhrzeit als "hh:mm" (Werte der Kalender-Felder).
|
||||||
{ value: 0, label: 'None' },
|
// Die Service-Art fragt das Formular nicht mehr ab: gespeichert wird Fernwartung.
|
||||||
{ value: 1, label: 'Low' },
|
|
||||||
{ value: 2, label: 'Medium' },
|
|
||||||
{ value: 3, label: 'High' },
|
|
||||||
{ value: 4, label: 'Critical' }
|
|
||||||
]
|
|
||||||
|
|
||||||
// Datum als "yyyy-mm-dd", Uhrzeit als "hh:mm" (Werte der Kalender-Felder)
|
|
||||||
const emptyForm = () => ({
|
const emptyForm = () => ({
|
||||||
customerId: '',
|
customerId: '',
|
||||||
type: TICKET_TYPES[0].value,
|
type: TICKET_TYPES[0].value,
|
||||||
serviceType: SERVICE_TYPES[0].value,
|
serviceType: SERVICE_TYPES[0].value,
|
||||||
priority: 1,
|
urgent: false,
|
||||||
topic: '',
|
topic: '',
|
||||||
requestedBy: '',
|
requestedBy: '',
|
||||||
requestedFor: '',
|
|
||||||
startDate: todayIso(),
|
startDate: todayIso(),
|
||||||
startTime: '',
|
startTime: '',
|
||||||
deadline: todayIso(),
|
deadline: todayIso(),
|
||||||
endTime: '',
|
endTime: '',
|
||||||
estimate: '30',
|
|
||||||
details: ''
|
details: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function CreateTicketModal({ isOpen, onClose, onCreate, customers = [] }) {
|
export default function CreateTicketModal({ isOpen, onClose, onCreate, customers = [] }) {
|
||||||
const { config } = useAdminConfig()
|
|
||||||
const { employees } = useEmployees()
|
const { employees } = useEmployees()
|
||||||
|
|
||||||
const PRIORITIES = config?.priorities || DEFAULT_PRIORITIES
|
|
||||||
const [formData, setFormData] = useState(emptyForm)
|
const [formData, setFormData] = useState(emptyForm)
|
||||||
const [recentTickets, setRecentTickets] = useState([])
|
const [recentTickets, setRecentTickets] = useState([])
|
||||||
|
|
||||||
@@ -104,9 +93,11 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
const customer = customers.find(c => c.$id === formData.customerId)
|
const customer = customers.find(c => c.$id === formData.customerId)
|
||||||
|
const { urgent, ...fields } = formData
|
||||||
try {
|
try {
|
||||||
const result = await onCreate({
|
const result = await onCreate({
|
||||||
...formData,
|
...fields,
|
||||||
|
priority: priorityFor(urgent),
|
||||||
customerName: customer?.name || '',
|
customerName: customer?.name || '',
|
||||||
customerLocation: customer?.location || '',
|
customerLocation: customer?.location || '',
|
||||||
startDate: isoToGermanDate(formData.startDate),
|
startDate: isoToGermanDate(formData.startDate),
|
||||||
@@ -136,7 +127,7 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
<FaTimes />
|
<FaTimes />
|
||||||
</span>
|
</span>
|
||||||
<div className="overlay-content">
|
<div className="overlay-content">
|
||||||
<h2 className="mb-2">Create New Ticket</h2>
|
<h2 className="mb-2">Neues Ticket</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' }}>
|
||||||
@@ -148,14 +139,14 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col col-6">
|
<div className="col col-6">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Customer ID</label>
|
<label className="form-label">Kunde</label>
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={formData.customerId}
|
value={formData.customerId}
|
||||||
onChange={(e) => handleChange('customerId', e.target.value)}
|
onChange={(e) => handleChange('customerId', e.target.value)}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Affected Customer</option>
|
<option value="">Kunde wählen</option>
|
||||||
{customers.map(c => (
|
{customers.map(c => (
|
||||||
<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>
|
||||||
))}
|
))}
|
||||||
@@ -163,7 +154,7 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Work Order Type</label>
|
<label className="form-label">Art</label>
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={formData.type}
|
value={formData.type}
|
||||||
@@ -176,31 +167,14 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Service Type</label>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||||
<select
|
<input
|
||||||
className="form-control"
|
type="checkbox"
|
||||||
value={formData.serviceType}
|
checked={formData.urgent}
|
||||||
onChange={(e) => handleChange('serviceType', e.target.value)}
|
onChange={(e) => handleChange('urgent', e.target.checked)}
|
||||||
>
|
/>
|
||||||
{SERVICE_TYPES.map(type => (
|
Dringend
|
||||||
<option key={type.value} value={type.value}>{type.label}</option>
|
</label>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Priority</label>
|
|
||||||
<select
|
|
||||||
className="form-control"
|
|
||||||
value={formData.priority}
|
|
||||||
onChange={(e) => handleChange('priority', parseInt(e.target.value))}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Priority Level</option>
|
|
||||||
{PRIORITIES.map(p => (
|
|
||||||
<option key={p.value} value={p.value}>{p.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group automatic-assignment-note">
|
<div className="form-group automatic-assignment-note">
|
||||||
@@ -215,11 +189,11 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
|
|
||||||
<div className="col col-6">
|
<div className="col col-6">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Topic</label>
|
<label className="form-label">Betreff</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
placeholder="Topic"
|
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
|
||||||
@@ -227,7 +201,7 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="ticket-requested-by">Requested by</label>
|
<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"
|
||||||
@@ -239,21 +213,10 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="ticket-requested-for">Requested for</label>
|
<label className="form-label" htmlFor="ticket-start-date">Beginn</label>
|
||||||
<AutocompleteInput
|
|
||||||
id="ticket-requested-for"
|
|
||||||
placeholder="Name"
|
|
||||||
value={formData.requestedFor}
|
|
||||||
onChange={(value) => handleChange('requestedFor', value)}
|
|
||||||
getSuggestions={getSuggestions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label" htmlFor="ticket-start-date">Start Date & Time</label>
|
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
id="ticket-start-date"
|
id="ticket-start-date"
|
||||||
timeLabel="Start time"
|
timeLabel="Uhrzeit Beginn"
|
||||||
date={formData.startDate}
|
date={formData.startDate}
|
||||||
time={formData.startTime}
|
time={formData.startTime}
|
||||||
onDateChange={handleStartDateChange}
|
onDateChange={handleStartDateChange}
|
||||||
@@ -262,10 +225,10 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
</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">Deadline</label>
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
id="ticket-end-date"
|
id="ticket-end-date"
|
||||||
timeLabel="End time"
|
timeLabel="Uhrzeit Deadline"
|
||||||
min={formData.startDate}
|
min={formData.startDate}
|
||||||
date={formData.deadline}
|
date={formData.deadline}
|
||||||
time={formData.endTime}
|
time={formData.endTime}
|
||||||
@@ -273,26 +236,15 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
onTimeChange={(value) => handleChange('endTime', value)}
|
onTimeChange={(value) => handleChange('endTime', value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Workload Estimate</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="xx minutes"
|
|
||||||
value={formData.estimate}
|
|
||||||
onChange={(e) => handleChange('estimate', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Details</label>
|
<label className="form-label">Beschreibung</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="form-control"
|
className="form-control"
|
||||||
rows={5}
|
rows={5}
|
||||||
placeholder="Details"
|
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)}
|
||||||
/>
|
/>
|
||||||
@@ -304,7 +256,7 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
|||||||
className="btn btn-dark"
|
className="btn btn-dark"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? 'Creating...' : 'CREATE NOW'}
|
{loading ? 'Wird angelegt …' : 'Ticket anlegen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,24 +2,18 @@ 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'
|
import { SERVICE_TYPES, todayIso, isoToGermanDate, timeToWoms, minutesBetween } from '../lib/ticketForm'
|
||||||
|
import { TICKET_STATUSES, statusLabel } from '../lib/ticketStatus'
|
||||||
import { DateTimeInput } from './ui'
|
import { DateTimeInput } from './ui'
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
|
||||||
'Open',
|
|
||||||
'Closed',
|
|
||||||
'Awaiting',
|
|
||||||
'Added Info',
|
|
||||||
'Occupied',
|
|
||||||
'Halted',
|
|
||||||
'Cancelled',
|
|
||||||
'Aborted',
|
|
||||||
'Assigned',
|
|
||||||
'In Test'
|
|
||||||
]
|
|
||||||
|
|
||||||
// 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']
|
||||||
|
|
||||||
|
// Die sechs Status; ein alter Status (z. B. "Assigned") bleibt als aktuelle Auswahl sichtbar
|
||||||
|
function statusOptionsFor(current) {
|
||||||
|
if (!current || TICKET_STATUSES.some((s) => s.value === current)) return TICKET_STATUSES
|
||||||
|
return [{ value: current, label: `${statusLabel(current)} (bisher)` }, ...TICKET_STATUSES]
|
||||||
|
}
|
||||||
|
|
||||||
// Datum als "yyyy-mm-dd", Uhrzeit als "hh:mm" (Werte der Kalender-Felder).
|
// 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.
|
// Die Service-Art kommt vom Ticket; das alte "Off Site" zaehlt als Fernwartung.
|
||||||
const initialForm = (workorder, newStatus) => ({
|
const initialForm = (workorder, newStatus) => ({
|
||||||
@@ -41,7 +35,9 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
||||||
const isAcquisition = workorder?.type === 'Akquise'
|
const isAcquisition = workorder?.type === 'Akquise'
|
||||||
const statusOptions = isAcquisition ? ACQUISITION_STATUS : STATUS_OPTIONS
|
const statusOptions = isAcquisition
|
||||||
|
? ACQUISITION_STATUS.map((s) => ({ value: s, label: s }))
|
||||||
|
: statusOptionsFor(workorder?.status)
|
||||||
const defaultStatus = isAcquisition
|
const defaultStatus = isAcquisition
|
||||||
? (ACQUISITION_STATUS.includes(workorder?.status) ? workorder.status : 'In Kontakt')
|
? (ACQUISITION_STATUS.includes(workorder?.status) ? workorder.status : 'In Kontakt')
|
||||||
: (workorder?.status || 'Open')
|
: (workorder?.status || 'Open')
|
||||||
@@ -122,7 +118,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
onClose()
|
onClose()
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Fehler beim Erstellen des Worksheets')
|
setError(result.error || 'Fehler beim Speichern des Arbeitsblatts')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error creating worksheet:', err)
|
console.error('Error creating worksheet:', err)
|
||||||
@@ -140,7 +136,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
<FaTimes />
|
<FaTimes />
|
||||||
</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">Neues Arbeitsblatt – Ticket {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' }}>
|
||||||
@@ -152,7 +148,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col col-6">
|
<div className="col col-6">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Service Type</label>
|
<label className="form-label">Art</label>
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={formData.serviceType}
|
value={formData.serviceType}
|
||||||
@@ -166,7 +162,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">{isAcquisition ? 'Akquise-Ergebnis' : 'New Status'}</label>
|
<label className="form-label">{isAcquisition ? 'Akquise-Ergebnis' : 'Neuer Status'}</label>
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={formData.newStatus}
|
value={formData.newStatus}
|
||||||
@@ -174,7 +170,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
required
|
required
|
||||||
>
|
>
|
||||||
{statusOptions.map(status => (
|
{statusOptions.map(status => (
|
||||||
<option key={status} value={status}>{status}</option>
|
<option key={status.value} value={status.value}>{status.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{isAcquisition && (
|
{isAcquisition && (
|
||||||
@@ -210,10 +206,10 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
|
|
||||||
<div className="col col-6">
|
<div className="col col-6">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="worksheet-start-date">Start Date & Time</label>
|
<label className="form-label" htmlFor="worksheet-start-date">Beginn</label>
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
id="worksheet-start-date"
|
id="worksheet-start-date"
|
||||||
timeLabel="Start time"
|
timeLabel="Uhrzeit Beginn"
|
||||||
required
|
required
|
||||||
date={formData.startDate}
|
date={formData.startDate}
|
||||||
time={formData.startTime}
|
time={formData.startTime}
|
||||||
@@ -223,10 +219,10 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="worksheet-end-date">End Date & Time</label>
|
<label className="form-label" htmlFor="worksheet-end-date">Ende</label>
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
id="worksheet-end-date"
|
id="worksheet-end-date"
|
||||||
timeLabel="End time"
|
timeLabel="Uhrzeit Ende"
|
||||||
required
|
required
|
||||||
min={formData.startDate}
|
min={formData.startDate}
|
||||||
date={formData.endDate}
|
date={formData.endDate}
|
||||||
@@ -237,7 +233,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Total Time (Minuten)</label>
|
<label className="form-label">Arbeitszeit (Minuten)</label>
|
||||||
{/* Ganze Minuten: mit step 15 blockierte der Browser berechnete Zeiten wie 70 */}
|
{/* Ganze Minuten: mit step 15 blockierte der Browser berechnete Zeiten wie 70 */}
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -259,7 +255,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Action Details</label>
|
<label className="form-label">Was wurde gemacht?</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="form-control"
|
className="form-control"
|
||||||
rows={5}
|
rows={5}
|
||||||
@@ -276,7 +272,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
className="btn btn-dark"
|
className="btn btn-dark"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? 'Creating...' : 'CREATE NOW'}
|
{loading ? 'Speichert …' : 'Speichern'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
const PRIORITIES = [
|
|
||||||
{ value: 0, label: 'None' },
|
|
||||||
{ value: 1, label: 'Low' },
|
|
||||||
{ value: 2, label: 'Medium' },
|
|
||||||
{ value: 3, label: 'High' },
|
|
||||||
{ value: 4, label: 'Critical' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const PRIORITY_LABELS = {
|
|
||||||
0: 'NONE',
|
|
||||||
1: 'LOW',
|
|
||||||
2: 'MEDIUM',
|
|
||||||
3: 'HIGH',
|
|
||||||
4: 'CRITICAL'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PriorityDropdown({ value, onChange }) {
|
|
||||||
return (
|
|
||||||
<div className="dropdown">
|
|
||||||
<button className="btn" style={{ background: 'inherit', color: 'inherit' }}>
|
|
||||||
{PRIORITY_LABELS[value] || 'LOW'}
|
|
||||||
</button>
|
|
||||||
<div className="dropdown-content">
|
|
||||||
{PRIORITIES.map(priority => (
|
|
||||||
<span
|
|
||||||
key={priority.value}
|
|
||||||
className="dropdown-item"
|
|
||||||
onClick={() => onChange(priority.value)}
|
|
||||||
>
|
|
||||||
{priority.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,19 @@
|
|||||||
const STATUSES = [
|
import { TICKET_STATUSES, statusLabel } from '../lib/ticketStatus'
|
||||||
'Open',
|
|
||||||
'Occupied',
|
|
||||||
'Assigned',
|
|
||||||
'Awaiting',
|
|
||||||
'Added Info',
|
|
||||||
'In Test',
|
|
||||||
'Halted',
|
|
||||||
'Aborted',
|
|
||||||
'Cancelled',
|
|
||||||
'Closed'
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function StatusDropdown({ value, onChange }) {
|
export default function StatusDropdown({ value, onChange }) {
|
||||||
return (
|
return (
|
||||||
<div className="dropdown">
|
<div className="dropdown">
|
||||||
<button className="btn" style={{ background: 'inherit', color: 'inherit' }}>
|
<button className="btn" style={{ background: 'inherit', color: 'inherit' }}>
|
||||||
{value?.toUpperCase() || 'OPEN'}
|
{statusLabel(value)}
|
||||||
</button>
|
</button>
|
||||||
<div className="dropdown-content">
|
<div className="dropdown-content">
|
||||||
{STATUSES.map(status => (
|
{TICKET_STATUSES.map(status => (
|
||||||
<span
|
<span
|
||||||
key={status}
|
key={status.value}
|
||||||
className="dropdown-item"
|
className="dropdown-item"
|
||||||
onClick={() => onChange(status)}
|
onClick={() => onChange(status.value)}
|
||||||
>
|
>
|
||||||
{status}
|
{status.label}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { FaChevronDown, FaPlay, FaStop, FaPlus, FaPen } from 'react-icons/fa6'
|
|||||||
import { formatDistanceToNow, format } from 'date-fns'
|
import { formatDistanceToNow, format } from 'date-fns'
|
||||||
import { de } from 'date-fns/locale'
|
import { de } from 'date-fns/locale'
|
||||||
import StatusDropdown from './StatusDropdown'
|
import StatusDropdown from './StatusDropdown'
|
||||||
import PriorityDropdown from './PriorityDropdown'
|
import UrgentToggle from './UrgentToggle'
|
||||||
import EditorDropdown from './EditorDropdown'
|
import EditorDropdown from './EditorDropdown'
|
||||||
import CreateWorksheetModal from './CreateWorksheetModal'
|
import CreateWorksheetModal from './CreateWorksheetModal'
|
||||||
import WorksheetList from './WorksheetList'
|
import WorksheetList from './WorksheetList'
|
||||||
import WorksheetTimesModal from './WorksheetTimesModal'
|
import WorksheetTimesModal from './WorksheetTimesModal'
|
||||||
import WorksheetStats from './WorksheetStats'
|
import WorksheetSummary from './WorksheetSummary'
|
||||||
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
|
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
|
||||||
import TicketProjectsModal from './TicketProjectsModal'
|
import TicketProjectsModal from './TicketProjectsModal'
|
||||||
import TicketRoadmapPanel from './TicketRoadmapPanel'
|
import TicketRoadmapPanel from './TicketRoadmapPanel'
|
||||||
@@ -17,18 +17,19 @@ import InvoicePanel from './InvoicePanel'
|
|||||||
import DocumentsPanel from './DocumentsPanel'
|
import DocumentsPanel from './DocumentsPanel'
|
||||||
import Tabs from './ui/Tabs'
|
import Tabs from './ui/Tabs'
|
||||||
import Badge from './ui/Badge'
|
import Badge from './ui/Badge'
|
||||||
import { StatusPill, PriorityPill } from './ui/StatusPill'
|
import { StatusPill, UrgentPill } from './ui/StatusPill'
|
||||||
import { useWorksheets } from '../hooks/useWorksheets'
|
import { useWorksheets } from '../hooks/useWorksheets'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
|
||||||
import { serviceTypeLabel, canEditWorksheetTimes } from '../lib/ticketForm'
|
import { canEditWorksheetTimes } from '../lib/ticketForm'
|
||||||
|
import { ticketTypeLabel } from '../lib/ticketStatus'
|
||||||
|
|
||||||
|
// Projekt / Preview steht mit im Details-Reiter
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'details', label: 'Details' },
|
{ id: 'details', label: 'Details' },
|
||||||
{ id: 'worksheets', label: 'Arbeitsblaetter' },
|
{ id: 'worksheets', label: 'Arbeitsblätter' },
|
||||||
{ id: 'invoices', label: 'Rechnungen' },
|
{ id: 'invoices', label: 'Rechnungen' },
|
||||||
{ id: 'documents', label: 'Dokumente' },
|
{ id: 'documents', label: 'Dokumente' },
|
||||||
{ id: 'project', label: 'Projekt / Preview' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function TicketCard({ ticket, onUpdate }) {
|
export default function TicketCard({ ticket, onUpdate }) {
|
||||||
@@ -89,9 +90,8 @@ export default function TicketCard({ ticket, onUpdate }) {
|
|||||||
<div className="tcard-topic">{ticket.topic || ticket.title || 'Kein Betreff'}</div>
|
<div className="tcard-topic">{ticket.topic || ticket.title || 'Kein Betreff'}</div>
|
||||||
<div className="tcard-badges">
|
<div className="tcard-badges">
|
||||||
<StatusPill status={ticket.status} />
|
<StatusPill status={ticket.status} />
|
||||||
<PriorityPill priority={ticket.priority} />
|
<UrgentPill priority={ticket.priority} />
|
||||||
{ticket.type && <Badge tone="muted">{ticket.type}</Badge>}
|
{ticket.type && <Badge tone="muted">{ticketTypeLabel(ticket.type)}</Badge>}
|
||||||
{ticket.serviceType && <Badge tone="info">{serviceTypeLabel(ticket.serviceType)}</Badge>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,9 +99,7 @@ export default function TicketCard({ ticket, onUpdate }) {
|
|||||||
<div style={{ minWidth: 120 }}>
|
<div style={{ minWidth: 120 }}>
|
||||||
<StatusDropdown value={ticket.status} onChange={(v) => onUpdate(ticket.$id, { status: v })} />
|
<StatusDropdown value={ticket.status} onChange={(v) => onUpdate(ticket.$id, { status: v })} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ minWidth: 110 }}>
|
<UrgentToggle value={ticket.priority} onChange={(v) => onUpdate(ticket.$id, { priority: v })} />
|
||||||
<PriorityDropdown value={ticket.priority} onChange={(v) => onUpdate(ticket.$id, { priority: v })} />
|
|
||||||
</div>
|
|
||||||
<FaChevronDown className={`chevron ${expanded ? 'up' : ''}`} onClick={() => setExpanded((e) => !e)} style={{ cursor: 'pointer' }} />
|
<FaChevronDown className={`chevron ${expanded ? 'up' : ''}`} onClick={() => setExpanded((e) => !e)} style={{ cursor: 'pointer' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,13 +119,18 @@ export default function TicketCard({ ticket, onUpdate }) {
|
|||||||
<div className="ticket-tab-content">
|
<div className="ticket-tab-content">
|
||||||
{activeTab === 'details' && (
|
{activeTab === 'details' && (
|
||||||
<div>
|
<div>
|
||||||
<div className="ui-card pad">
|
<div className="ui-card pad" style={{ marginBottom: 16 }}>
|
||||||
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Ticket-Beschreibung</h5>
|
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Ticket-Beschreibung</h5>
|
||||||
<p style={{ whiteSpace: 'pre-wrap', color: 'var(--text-muted)', lineHeight: 1.7, margin: 0 }}>
|
<p style={{ whiteSpace: 'pre-wrap', color: 'var(--text-muted)', lineHeight: 1.7, margin: 0 }}>
|
||||||
{ticket.details || 'Keine Details vorhanden.'}
|
{ticket.details || 'Keine Details vorhanden.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProjectContextPanel ticket={ticket} />
|
<ProjectContextPanel ticket={ticket} />
|
||||||
|
<TicketRoadmapPanel ticket={ticket} />
|
||||||
|
<button className="ui-btn ui-btn-primary" style={{ marginBottom: 12 }} onClick={() => setShowProjectsModal(true)}>
|
||||||
|
<FaPen /> Projekte zuweisen / bearbeiten
|
||||||
|
</button>
|
||||||
|
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -135,15 +138,10 @@ export default function TicketCard({ ticket, onUpdate }) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex gap-2" style={{ marginBottom: 16 }}>
|
<div className="flex gap-2" style={{ marginBottom: 16 }}>
|
||||||
<button className="ui-btn ui-btn-primary grow" onClick={() => setShowCreateWorksheet(true)}>
|
<button className="ui-btn ui-btn-primary grow" onClick={() => setShowCreateWorksheet(true)}>
|
||||||
<FaPlus /> Arbeitsblatt hinzufuegen
|
<FaPlus /> Arbeitsblatt hinzufügen
|
||||||
</button>
|
</button>
|
||||||
<button className="ui-btn" onClick={() => setShowProjectsModal(true)} title="Projekte"><FaPen /></button>
|
|
||||||
</div>
|
</div>
|
||||||
{worksheets.length > 0 && (
|
<WorksheetSummary worksheets={worksheets} />
|
||||||
<div className="ui-card pad" style={{ marginBottom: 16 }}>
|
|
||||||
<WorksheetStats worksheets={worksheets} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<WorksheetList
|
<WorksheetList
|
||||||
worksheets={worksheets}
|
worksheets={worksheets}
|
||||||
totalTime={getTotalTime()}
|
totalTime={getTotalTime()}
|
||||||
@@ -157,15 +155,6 @@ export default function TicketCard({ ticket, onUpdate }) {
|
|||||||
|
|
||||||
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
|
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
|
||||||
{activeTab === 'documents' && <DocumentsPanel ticket={ticket} />}
|
{activeTab === 'documents' && <DocumentsPanel ticket={ticket} />}
|
||||||
{activeTab === 'project' && (
|
|
||||||
<div>
|
|
||||||
<TicketRoadmapPanel ticket={ticket} />
|
|
||||||
<button className="ui-btn ui-btn-primary" style={{ marginBottom: 12 }} onClick={() => setShowProjectsModal(true)}>
|
|
||||||
<FaPen /> Projekte zuweisen / bearbeiten
|
|
||||||
</button>
|
|
||||||
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { isUrgent, priorityFor } from '../lib/ticketStatus'
|
||||||
|
|
||||||
|
// Ein Klick markiert ein Ticket als dringend oder wieder als normal
|
||||||
|
export default function UrgentToggle({ value, onChange }) {
|
||||||
|
const urgent = isUrgent(value)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`urgent-toggle${urgent ? ' on' : ''}`}
|
||||||
|
aria-pressed={urgent}
|
||||||
|
title={urgent ? 'Nicht mehr dringend' : 'Als dringend markieren'}
|
||||||
|
onClick={() => onChange(priorityFor(!urgent))}
|
||||||
|
>
|
||||||
|
Dringend
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp, FaPen, FaCheck } from 'react-icons/fa'
|
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp, FaPen, FaCheck } from 'react-icons/fa'
|
||||||
import { serviceTypeLabel, needsTimeEntry, worksheetTone } from '../lib/ticketForm'
|
import { serviceTypeLabel, needsTimeEntry, worksheetTone } from '../lib/ticketForm'
|
||||||
|
import { statusLabel } from '../lib/ticketStatus'
|
||||||
|
|
||||||
// Kopffarbe je Zustand (worksheetTone): orange = Git-Zeit fehlt, gruen = Git-Zeit eingetragen
|
// Kopffarbe je Zustand (worksheetTone): orange = Git-Zeit fehlt, gruen = Git-Zeit eingetragen
|
||||||
const TONES = {
|
const TONES = {
|
||||||
@@ -29,7 +30,7 @@ export default function WorksheetList({ worksheets, totalTime, loading, onToggle
|
|||||||
return (
|
return (
|
||||||
<div className="text-center p-4">
|
<div className="text-center p-4">
|
||||||
<div className="spinner-border" role="status">
|
<div className="spinner-border" role="status">
|
||||||
<span className="visually-hidden">Loading...</span>
|
<span className="visually-hidden">Lädt …</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -39,7 +40,7 @@ export default function WorksheetList({ worksheets, totalTime, loading, onToggle
|
|||||||
return (
|
return (
|
||||||
<div className="alert alert-info" role="alert">
|
<div className="alert alert-info" role="alert">
|
||||||
<FaComment className="me-2" />
|
<FaComment className="me-2" />
|
||||||
Noch keine Worksheets vorhanden. Erstelle das erste Worksheet für dieses Ticket.
|
Noch keine Arbeitsblätter. Leg das erste mit „Arbeitsblatt hinzufügen“ an.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -252,9 +253,9 @@ export default function WorksheetList({ worksheets, totalTime, loading, onToggle
|
|||||||
}}>
|
}}>
|
||||||
<FaExchangeAlt className="me-2" style={{ color: '#10b981' }} />
|
<FaExchangeAlt className="me-2" style={{ color: '#10b981' }} />
|
||||||
<span className="text-muted">Status:</span>{' '}
|
<span className="text-muted">Status:</span>{' '}
|
||||||
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldStatus}</span>
|
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{statusLabel(ws.oldStatus)}</span>
|
||||||
<span className="mx-2" style={{ color: '#10b981' }}>→</span>
|
<span className="mx-2" style={{ color: '#10b981' }}>→</span>
|
||||||
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newStatus}</span>
|
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{statusLabel(ws.newStatus)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,313 +0,0 @@
|
|||||||
import { FaClock, FaUsers, FaChartLine } from 'react-icons/fa'
|
|
||||||
import { serviceTypeLabel } from '../lib/ticketForm'
|
|
||||||
|
|
||||||
export default function WorksheetStats({ worksheets }) {
|
|
||||||
if (!worksheets || worksheets.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gesamtarbeitszeit
|
|
||||||
const totalMinutes = worksheets
|
|
||||||
.filter(ws => !ws.isComment)
|
|
||||||
.reduce((sum, ws) => sum + (ws.totalTime || 0), 0)
|
|
||||||
|
|
||||||
// Nach Mitarbeiter gruppieren
|
|
||||||
const byEmployee = worksheets.reduce((acc, ws) => {
|
|
||||||
const empId = ws.employeeId
|
|
||||||
if (!acc[empId]) {
|
|
||||||
acc[empId] = {
|
|
||||||
name: ws.employeeName,
|
|
||||||
short: ws.employeeShort,
|
|
||||||
time: 0,
|
|
||||||
count: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!ws.isComment) {
|
|
||||||
acc[empId].time += ws.totalTime || 0
|
|
||||||
}
|
|
||||||
acc[empId].count += 1
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
|
|
||||||
// Service Type Verteilung (Remote und das alte Off Site zusammen als Fernwartung)
|
|
||||||
const byServiceType = worksheets.reduce((acc, ws) => {
|
|
||||||
const type = serviceTypeLabel(ws.serviceType) || 'Unknown'
|
|
||||||
acc[type] = (acc[type] || 0) + 1
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
const formatTime = (minutes) => {
|
|
||||||
if (!minutes || minutes === 0) return '0min'
|
|
||||||
const hours = Math.floor(minutes / 60)
|
|
||||||
const mins = minutes % 60
|
|
||||||
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="worksheet-stats" style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '16px',
|
|
||||||
height: '100%'
|
|
||||||
}}>
|
|
||||||
{/* Gesamtübersicht */}
|
|
||||||
<div style={{
|
|
||||||
background: 'rgba(45, 55, 72, 0.5)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '16px',
|
|
||||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
|
||||||
flex: '0 0 auto'
|
|
||||||
}}>
|
|
||||||
<h6 style={{
|
|
||||||
color: 'var(--dark-text)',
|
|
||||||
marginBottom: '12px',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px'
|
|
||||||
}}>
|
|
||||||
<FaChartLine size={16} style={{ color: '#10b981' }} />
|
|
||||||
Gesamtübersicht
|
|
||||||
</h6>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '8px',
|
|
||||||
background: 'rgba(26, 32, 44, 0.4)',
|
|
||||||
borderRadius: '6px'
|
|
||||||
}}>
|
|
||||||
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Worksheets:</span>
|
|
||||||
<strong style={{ color: 'var(--dark-text)', fontSize: '16px' }}>{worksheets.length}</strong>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '8px',
|
|
||||||
background: 'rgba(26, 32, 44, 0.4)',
|
|
||||||
borderRadius: '6px'
|
|
||||||
}}>
|
|
||||||
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Arbeitszeit:</span>
|
|
||||||
<strong style={{ color: '#10b981', fontSize: '16px' }}>{formatTime(totalMinutes)}</strong>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '8px',
|
|
||||||
background: 'rgba(26, 32, 44, 0.4)',
|
|
||||||
borderRadius: '6px'
|
|
||||||
}}>
|
|
||||||
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Kommentare:</span>
|
|
||||||
<strong style={{ color: 'var(--dark-text)', fontSize: '16px' }}>{worksheets.filter(ws => ws.isComment).length}</strong>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '8px',
|
|
||||||
background: 'rgba(26, 32, 44, 0.4)',
|
|
||||||
borderRadius: '6px'
|
|
||||||
}}>
|
|
||||||
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Ø pro WS:</span>
|
|
||||||
<strong style={{ color: 'var(--dark-text)', fontSize: '16px' }}>
|
|
||||||
{formatTime(Math.round(totalMinutes / (worksheets.filter(ws => !ws.isComment).length || 1)))}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mitarbeiter Kombiniertes Diagramm */}
|
|
||||||
<div style={{
|
|
||||||
background: 'rgba(45, 55, 72, 0.5)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '16px',
|
|
||||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
|
||||||
flex: '1 1 auto',
|
|
||||||
minHeight: '200px'
|
|
||||||
}}>
|
|
||||||
<h6 style={{
|
|
||||||
color: 'var(--dark-text)',
|
|
||||||
marginBottom: '16px',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px'
|
|
||||||
}}>
|
|
||||||
<FaUsers size={16} style={{ color: '#10b981' }} />
|
|
||||||
Mitarbeiter-Statistiken
|
|
||||||
</h6>
|
|
||||||
|
|
||||||
{Object.keys(byEmployee).length === 0 ? (
|
|
||||||
<div style={{ color: '#a0aec0', textAlign: 'center', padding: '20px' }}>
|
|
||||||
Keine Mitarbeiter-Daten verfügbar
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
||||||
{(() => {
|
|
||||||
const employeeArray = Object.values(byEmployee).sort((a, b) => b.time - a.time)
|
|
||||||
const maxTime = Math.max(...employeeArray.map(e => e.time), 1)
|
|
||||||
|
|
||||||
return employeeArray.map((emp, idx) => {
|
|
||||||
const percentage = maxTime > 0 ? (emp.time / maxTime) * 100 : 0
|
|
||||||
return (
|
|
||||||
<div key={idx}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: '6px',
|
|
||||||
fontSize: '11px'
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
color: 'var(--dark-text)',
|
|
||||||
fontWeight: '500',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
minWidth: '120px'
|
|
||||||
}}>
|
|
||||||
{emp.short && (
|
|
||||||
<span style={{
|
|
||||||
background: 'rgba(16, 185, 129, 0.2)',
|
|
||||||
color: '#10b981',
|
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: '3px',
|
|
||||||
fontSize: '9px',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}>{emp.short}</span>
|
|
||||||
)}
|
|
||||||
<span style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{emp.name}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '28px',
|
|
||||||
background: 'rgba(26, 32, 44, 0.6)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}>
|
|
||||||
{/* WS Anzahl am Anfang */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: '8px',
|
|
||||||
zIndex: 2,
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px'
|
|
||||||
}}>
|
|
||||||
<span>WS</span>
|
|
||||||
<span style={{
|
|
||||||
background: 'rgba(255, 255, 255, 0.3)',
|
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: '4px'
|
|
||||||
}}>{emp.count}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Balken mit Zeit */}
|
|
||||||
<div style={{
|
|
||||||
width: `${percentage}%`,
|
|
||||||
height: '100%',
|
|
||||||
background: 'linear-gradient(90deg, #10b981 0%, #059669 100%)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
transition: 'width 0.5s ease',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
paddingRight: '8px',
|
|
||||||
paddingLeft: '60px',
|
|
||||||
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
|
|
||||||
position: 'relative'
|
|
||||||
}}>
|
|
||||||
{/* Zeit am Ende des Balkens */}
|
|
||||||
<span style={{
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}>
|
|
||||||
{formatTime(emp.time)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Zeit außerhalb des Balkens (falls Balken zu kurz) */}
|
|
||||||
{percentage < 30 && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: '8px',
|
|
||||||
zIndex: 2,
|
|
||||||
color: '#10b981',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}>
|
|
||||||
{formatTime(emp.time)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Service Type Verteilung */}
|
|
||||||
<div style={{
|
|
||||||
background: 'rgba(45, 55, 72, 0.5)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '16px',
|
|
||||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
|
||||||
flex: '0 0 auto'
|
|
||||||
}}>
|
|
||||||
<h6 style={{
|
|
||||||
color: 'var(--dark-text)',
|
|
||||||
marginBottom: '12px',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px'
|
|
||||||
}}>
|
|
||||||
<FaClock size={16} style={{ color: '#10b981' }} />
|
|
||||||
Service Types
|
|
||||||
</h6>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
{Object.entries(byServiceType).map(([type, count]) => (
|
|
||||||
<div key={type} style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '8px',
|
|
||||||
background: 'rgba(26, 32, 44, 0.4)',
|
|
||||||
borderRadius: '6px'
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
color: 'var(--dark-text)',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '500'
|
|
||||||
}}>{type}</span>
|
|
||||||
<strong style={{
|
|
||||||
color: '#10b981',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}>{count}</strong>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { needsTimeEntry } from '../lib/ticketForm'
|
||||||
|
|
||||||
|
const formatTime = (minutes) => {
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const mins = minutes % 60
|
||||||
|
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Zeile ueber der Arbeitsblatt-Liste: Arbeitszeit, Anzahl, offene Git-Zeiten
|
||||||
|
export default function WorksheetSummary({ worksheets }) {
|
||||||
|
if (!worksheets || worksheets.length === 0) return null
|
||||||
|
const minutes = worksheets
|
||||||
|
.filter((ws) => !ws.isComment)
|
||||||
|
.reduce((sum, ws) => sum + (Number(ws.totalTime) || 0), 0)
|
||||||
|
const open = worksheets.filter(needsTimeEntry).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="worksheet-summary">
|
||||||
|
<span>Arbeitszeit gesamt <strong>{formatTime(minutes)}</strong></span>
|
||||||
|
<span>{worksheets.length} {worksheets.length === 1 ? 'Arbeitsblatt' : 'Arbeitsblätter'}</span>
|
||||||
|
{open > 0 && <span className="open">{open} {open === 1 ? 'Zeit offen' : 'Zeiten offen'}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,19 +1,4 @@
|
|||||||
const STATUS_CLASS = {
|
import { statusLabel, statusClass, isUrgent } from '../../lib/ticketStatus'
|
||||||
Open: 'status-open',
|
|
||||||
Occupied: 'status-occupied',
|
|
||||||
Assigned: 'status-assigned',
|
|
||||||
Awaiting: 'status-awaiting',
|
|
||||||
'Added Info': 'status-awaiting',
|
|
||||||
Closed: 'status-closed',
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRIORITY = {
|
|
||||||
0: { cls: 'priority-none', label: 'Keine' },
|
|
||||||
1: { cls: 'priority-low', label: 'Niedrig' },
|
|
||||||
2: { cls: 'priority-medium', label: 'Mittel' },
|
|
||||||
3: { cls: 'priority-high', label: 'Hoch' },
|
|
||||||
4: { cls: 'priority-critical', label: 'Kritisch' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const pillBase = {
|
const pillBase = {
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
@@ -26,11 +11,11 @@ const pillBase = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StatusPill({ status }) {
|
export function StatusPill({ status }) {
|
||||||
const cls = STATUS_CLASS[status] || 'status-open'
|
return <span className={statusClass(status)} style={pillBase}>{statusLabel(status)}</span>
|
||||||
return <span className={cls} style={pillBase}>{status || 'Open'}</span>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PriorityPill({ priority }) {
|
// Nur dringende Tickets bekommen ein Abzeichen
|
||||||
const p = PRIORITY[priority] ?? PRIORITY[1]
|
export function UrgentPill({ priority }) {
|
||||||
return <span className={p.cls} style={pillBase}>{p.label}</span>
|
if (!isUrgent(priority)) return null
|
||||||
|
return <span className="priority-critical" style={pillBase}>Dringend</span>
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ export { default as PageHeader } from './PageHeader'
|
|||||||
export { default as Card } from './Card'
|
export { default as Card } from './Card'
|
||||||
export { default as Button } from './Button'
|
export { default as Button } from './Button'
|
||||||
export { default as Badge } from './Badge'
|
export { default as Badge } from './Badge'
|
||||||
export { StatusPill, PriorityPill } from './StatusPill'
|
export { StatusPill, UrgentPill } from './StatusPill'
|
||||||
export { default as Tabs } from './Tabs'
|
export { default as Tabs } from './Tabs'
|
||||||
export { default as EmptyState } from './EmptyState'
|
export { default as EmptyState } from './EmptyState'
|
||||||
export { default as Field } from './Field'
|
export { default as Field } from './Field'
|
||||||
|
|||||||
+28
-26
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
|
||||||
|
import { ticketMatches, NORMAL_PRIORITY } from '../lib/ticketStatus'
|
||||||
|
|
||||||
function buildFiltersKey(filters = {}) {
|
function buildFiltersKey(filters = {}) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
@@ -10,6 +11,8 @@ function buildFiltersKey(filters = {}) {
|
|||||||
type: filters.type ?? null,
|
type: filters.type ?? null,
|
||||||
priority: filters.priority ?? null,
|
priority: filters.priority ?? null,
|
||||||
woid: filters.woid ?? null,
|
woid: filters.woid ?? null,
|
||||||
|
excludeType: filters.excludeType ?? null,
|
||||||
|
search: filters.search ?? null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +28,8 @@ export function useWorkorders(filters = {}) {
|
|||||||
filters.type,
|
filters.type,
|
||||||
filters.priority,
|
filters.priority,
|
||||||
filters.woid,
|
filters.woid,
|
||||||
|
filters.excludeType,
|
||||||
|
filters.search,
|
||||||
])
|
])
|
||||||
|
|
||||||
const fetchWorkorders = useCallback(async () => {
|
const fetchWorkorders = useCallback(async () => {
|
||||||
@@ -33,8 +38,11 @@ export function useWorkorders(filters = {}) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const queries = [Query.orderDesc('$createdAt')]
|
const queries = [Query.orderDesc('$createdAt')]
|
||||||
|
|
||||||
if (activeFilters.limit) {
|
// Die Suche laeuft hier im Browser ueber alle Tickets, die zu den Filtern passen
|
||||||
|
if (activeFilters.search) {
|
||||||
|
queries.push(Query.limit(5000))
|
||||||
|
} else if (activeFilters.limit) {
|
||||||
queries.push(Query.limit(activeFilters.limit))
|
queries.push(Query.limit(activeFilters.limit))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +57,11 @@ export function useWorkorders(filters = {}) {
|
|||||||
queries.push(Query.equal('type', activeFilters.type))
|
queries.push(Query.equal('type', activeFilters.type))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standardansicht "Auftraege": alles ausser Akquise-Tickets
|
||||||
|
if (activeFilters.excludeType) {
|
||||||
|
queries.push(Query.notEqual('type', activeFilters.excludeType))
|
||||||
|
}
|
||||||
|
|
||||||
if (activeFilters.priority && activeFilters.priority.length > 0) {
|
if (activeFilters.priority && activeFilters.priority.length > 0) {
|
||||||
queries.push(Query.equal('priority', activeFilters.priority))
|
queries.push(Query.equal('priority', activeFilters.priority))
|
||||||
}
|
}
|
||||||
@@ -80,7 +93,11 @@ export function useWorkorders(filters = {}) {
|
|||||||
queries
|
queries
|
||||||
)
|
)
|
||||||
|
|
||||||
setWorkorders(response.documents)
|
setWorkorders(
|
||||||
|
activeFilters.search
|
||||||
|
? response.documents.filter((ticket) => ticketMatches(ticket, activeFilters.search))
|
||||||
|
: response.documents
|
||||||
|
)
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
|
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
|
||||||
@@ -155,18 +172,18 @@ export function useWorkorders(filters = {}) {
|
|||||||
try {
|
try {
|
||||||
// Validierung: Prüfe required fields
|
// Validierung: Prüfe required fields
|
||||||
if (!data.topic || data.topic.trim() === '') {
|
if (!data.topic || data.topic.trim() === '') {
|
||||||
return { success: false, error: 'Das Feld "Topic" ist erforderlich.' }
|
return { success: false, error: 'Bitte einen Betreff eingeben.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const automatic = await resolveAutomaticTicketFields(data.assignedTo)
|
const automatic = await resolveAutomaticTicketFields(data.assignedTo)
|
||||||
const autoStatus = automatic.assignedTo ? 'Assigned' : 'Open'
|
|
||||||
|
// Bereite Daten für Appwrite vor. Neue Tickets starten "Offen", auch wenn sie
|
||||||
// Bereite Daten für Appwrite vor
|
// gleich jemandem zugewiesen werden - wer zustaendig ist, zeigt das Kuerzel.
|
||||||
const workorderData = {
|
const workorderData = {
|
||||||
// Required fields
|
// Required fields
|
||||||
topic: data.topic.trim(),
|
topic: data.topic.trim(),
|
||||||
status: autoStatus,
|
status: 'Open',
|
||||||
priority: typeof data.priority === 'number' ? data.priority : parseInt(data.priority) || 1,
|
priority: typeof data.priority === 'number' ? data.priority : parseInt(data.priority) || NORMAL_PRIORITY,
|
||||||
woid: automatic.woid,
|
woid: automatic.woid,
|
||||||
|
|
||||||
// Optional fields - nur senden wenn vorhanden
|
// Optional fields - nur senden wenn vorhanden
|
||||||
@@ -228,27 +245,12 @@ export function useWorkorders(filters = {}) {
|
|||||||
|
|
||||||
const updateWorkorder = async (id, data) => {
|
const updateWorkorder = async (id, data) => {
|
||||||
try {
|
try {
|
||||||
// Status-Automatik beim Update:
|
// Zuweisen aendert den Status nicht (frueher sprang er auf "Assigned")
|
||||||
// Wenn assignedTo gesetzt wird → Status = "Assigned"
|
|
||||||
// Wenn assignedTo entfernt wird UND Status = "Assigned" → Status = "Open"
|
|
||||||
const updateData = { ...data }
|
|
||||||
if ('assignedTo' in updateData) {
|
|
||||||
if (updateData.assignedTo && updateData.assignedTo !== '') {
|
|
||||||
// Mitarbeiter zugewiesen → Status auf "Assigned" setzen
|
|
||||||
if (!updateData.status) {
|
|
||||||
updateData.status = 'Assigned'
|
|
||||||
}
|
|
||||||
} else if (!updateData.status) {
|
|
||||||
// Keine Zuweisung mehr → Status auf "Open" setzen (nur wenn nicht explizit anders gesetzt)
|
|
||||||
updateData.status = 'Open'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await databases.updateDocument(
|
const response = await databases.updateDocument(
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
COLLECTIONS.WORKORDERS,
|
COLLECTIONS.WORKORDERS,
|
||||||
id,
|
id,
|
||||||
updateData
|
data
|
||||||
)
|
)
|
||||||
setWorkorders(prev =>
|
setWorkorders(prev =>
|
||||||
prev.map(wo => wo.$id === id ? response : wo)
|
prev.map(wo => wo.$id === id ? response : wo)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/* Ticket-Status, "Dringend" und Namen der Ticket-Arten fuer die Oberflaeche.
|
||||||
|
Gespeichert werden weiter die englischen Werte: das Kundenportal leitet daraus
|
||||||
|
die Fortschrittsleiste der Kunden ab (Open = Eingegangen, Occupied = In Arbeit,
|
||||||
|
Awaiting = Wartet auf deine Rueckmeldung, In Test = Wird geprueft, Closed = Erledigt,
|
||||||
|
Cancelled = Storniert). */
|
||||||
|
|
||||||
|
// Diese sechs Status lassen sich waehlen
|
||||||
|
export const TICKET_STATUSES = [
|
||||||
|
{ value: 'Open', label: 'Offen' },
|
||||||
|
{ value: 'Occupied', label: 'In Arbeit' },
|
||||||
|
{ value: 'Awaiting', label: 'Wartet auf Kunde' },
|
||||||
|
{ value: 'In Test', label: 'Prüfung' },
|
||||||
|
{ value: 'Closed', label: 'Erledigt' },
|
||||||
|
{ value: 'Cancelled', label: 'Storniert' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Alte Werte (nicht mehr waehlbar) und die Ergebnisse der Akquise-Tickets bleiben lesbar
|
||||||
|
const OTHER_LABELS = {
|
||||||
|
Assigned: 'Eingeplant',
|
||||||
|
'Added Info': 'In Arbeit',
|
||||||
|
Halted: 'Pausiert',
|
||||||
|
Aborted: 'Abgebrochen',
|
||||||
|
'In Kontakt': 'In Kontakt',
|
||||||
|
Zugesagt: 'Zugesagt',
|
||||||
|
Abgesagt: 'Abgesagt',
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS = { ...Object.fromEntries(TICKET_STATUSES.map((s) => [s.value, s.label])), ...OTHER_LABELS }
|
||||||
|
|
||||||
|
export function statusLabel(status) {
|
||||||
|
if (!status) return 'Offen'
|
||||||
|
return LABELS[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: jede Gruppe fasst die gespeicherten Werte zusammen, auch die alten
|
||||||
|
export const STATUS_GROUPS = [
|
||||||
|
{ id: 'open', label: 'Offen', values: ['Open'] },
|
||||||
|
{ id: 'working', label: 'In Arbeit', values: ['Occupied', 'Assigned', 'Added Info', 'In Kontakt'] },
|
||||||
|
{ id: 'waiting', label: 'Wartet auf Kunde', values: ['Awaiting', 'Halted'] },
|
||||||
|
{ id: 'review', label: 'Prüfung', values: ['In Test'] },
|
||||||
|
{ id: 'done', label: 'Erledigt', values: ['Closed', 'Zugesagt'] },
|
||||||
|
{ id: 'cancelled', label: 'Storniert', values: ['Cancelled', 'Aborted', 'Abgesagt'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Standardansicht: alles, was noch nicht erledigt oder storniert ist
|
||||||
|
export const DEFAULT_STATUS_GROUPS = ['open', 'working', 'waiting', 'review']
|
||||||
|
|
||||||
|
export function statusGroup(status) {
|
||||||
|
return STATUS_GROUPS.find((group) => group.values.includes(status))?.id || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusValuesFor(groupIds) {
|
||||||
|
return STATUS_GROUPS.filter((group) => groupIds.includes(group.id)).flatMap((group) => group.values)
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_CLASS = {
|
||||||
|
open: 'status-open',
|
||||||
|
working: 'status-working',
|
||||||
|
waiting: 'status-awaiting',
|
||||||
|
review: 'status-assigned',
|
||||||
|
done: 'status-closed',
|
||||||
|
cancelled: 'status-closed',
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS-Klasse fuer das Status-Abzeichen
|
||||||
|
export function statusClass(status) {
|
||||||
|
return GROUP_CLASS[statusGroup(status || 'Open')] || 'status-open'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statt fuenf Prioritaeten gibt es nur noch "dringend" oder nicht.
|
||||||
|
// Frueher gespeicherte Stufen: Hoch (3) und Kritisch (4) gelten als dringend.
|
||||||
|
export const NORMAL_PRIORITY = 2
|
||||||
|
export const URGENT_PRIORITY = 4
|
||||||
|
|
||||||
|
export function isUrgent(priority) {
|
||||||
|
return Number(priority) >= 3
|
||||||
|
}
|
||||||
|
|
||||||
|
export function priorityFor(urgent) {
|
||||||
|
return urgent ? URGENT_PRIORITY : NORMAL_PRIORITY
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS = { Project: 'Projekt' }
|
||||||
|
|
||||||
|
export function ticketTypeLabel(type) {
|
||||||
|
return TYPE_LABELS[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter nach Art: "Auftraege" = alles ausser Akquise
|
||||||
|
export const TYPE_FILTERS = [
|
||||||
|
{ id: 'orders', label: 'Aufträge', excludeType: 'Akquise' },
|
||||||
|
{ id: 'Webpage', label: 'Webpage', types: ['Webpage'] },
|
||||||
|
{ id: 'Migration', label: 'Migration', types: ['Migration'] },
|
||||||
|
{ id: 'Project', label: 'Projekt', types: ['Project'] },
|
||||||
|
{ id: 'Akquise', label: 'Akquise', types: ['Akquise'] },
|
||||||
|
{ id: 'all', label: 'Alle' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Suche in Kunde, Betreff, "Angefragt von" und WOID, ohne Gross-/Kleinschreibung
|
||||||
|
export function ticketMatches(ticket, query) {
|
||||||
|
const q = String(query || '').trim().toLowerCase()
|
||||||
|
if (!q) return true
|
||||||
|
return [ticket.customerName, ticket.topic, ticket.title, ticket.requestedBy, ticket.woid]
|
||||||
|
.some((value) => String(value || '').toLowerCase().includes(q))
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { useParams, Link } from 'react-router-dom'
|
|||||||
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaArrowUpRightFromSquare, FaBoxArchive, FaBoxOpen } from 'react-icons/fa6'
|
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaArrowUpRightFromSquare, FaBoxArchive, FaBoxOpen } from 'react-icons/fa6'
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||||
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
|
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
|
||||||
import { StatusPill, PriorityPill } from '../components/ui/StatusPill'
|
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
||||||
import InvoicePanel from '../components/InvoicePanel'
|
import InvoicePanel from '../components/InvoicePanel'
|
||||||
import DocumentsPanel from '../components/DocumentsPanel'
|
import DocumentsPanel from '../components/DocumentsPanel'
|
||||||
import AssignedProjectCard from '../components/AssignedProjectCard'
|
import AssignedProjectCard from '../components/AssignedProjectCard'
|
||||||
@@ -307,7 +307,7 @@ function CustomerTickets({ customerId }) {
|
|||||||
<div style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
|
<div style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
|
||||||
<div className="muted">{t.type}</div>
|
<div className="muted">{t.type}</div>
|
||||||
</div>
|
</div>
|
||||||
<PriorityPill priority={t.priority} />
|
<UrgentPill priority={t.priority} />
|
||||||
<StatusPill status={t.status} />
|
<StatusPill status={t.status} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { FaTicket, FaClipboardList, FaClock, FaCircleCheck, FaPlus, FaFileInvoic
|
|||||||
import { integrationsApi, formatMoney } from '../lib/integrationsApi'
|
import { integrationsApi, formatMoney } from '../lib/integrationsApi'
|
||||||
import { useFinanceSummary } from '../hooks/useFinanceSummary'
|
import { useFinanceSummary } from '../hooks/useFinanceSummary'
|
||||||
import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
|
import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
|
||||||
import { StatusPill, PriorityPill } from '../components/ui/StatusPill'
|
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { summary, loading: finLoading } = useFinanceSummary()
|
const { summary, loading: finLoading } = useFinanceSummary()
|
||||||
@@ -83,7 +83,7 @@ export default function DashboardPage() {
|
|||||||
<div className="text-ellipsis" style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
|
<div className="text-ellipsis" style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
|
||||||
<div className="muted">{t.customerName || '-'}</div>
|
<div className="muted">{t.customerName || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
<PriorityPill priority={t.priority} />
|
<UrgentPill priority={t.priority} />
|
||||||
<StatusPill status={t.status} />
|
<StatusPill status={t.status} />
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+74
-71
@@ -6,59 +6,61 @@ import { useCustomers } from '../hooks/useCustomers'
|
|||||||
import TicketCard from '../components/TicketCard'
|
import TicketCard from '../components/TicketCard'
|
||||||
import CreateTicketModal from '../components/CreateTicketModal'
|
import CreateTicketModal from '../components/CreateTicketModal'
|
||||||
import { PageHeader, Card, Button, EmptyState } from '../components/ui'
|
import { PageHeader, Card, Button, EmptyState } from '../components/ui'
|
||||||
|
import { STATUS_GROUPS, DEFAULT_STATUS_GROUPS, statusValuesFor, TYPE_FILTERS } from '../lib/ticketStatus'
|
||||||
|
|
||||||
// Alle Ticket-Status; "Closed" gilt als archiviert und ist standardmaessig ausgeblendet
|
const ALL_GROUPS = STATUS_GROUPS.map((group) => group.id)
|
||||||
const ALL_STATUSES = [
|
const linkButton = { fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 }
|
||||||
'Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info',
|
|
||||||
'In Test', 'Halted', 'Aborted', 'Cancelled', 'Closed',
|
// Aus Suchtext, Art, Status-Gruppen und "Nur dringende" die Abfrage fuer useWorkorders bauen.
|
||||||
]
|
// Sind alle oder keine Status-Gruppen gewaehlt, wird nicht nach Status gefiltert.
|
||||||
const ARCHIVED_STATUSES = ['Closed']
|
function toFilters({ search, typeId, groups, urgentOnly }) {
|
||||||
const DEFAULT_STATUSES = ALL_STATUSES.filter((s) => !ARCHIVED_STATUSES.includes(s))
|
const typeFilter = TYPE_FILTERS.find((filter) => filter.id === typeId) || TYPE_FILTERS[0]
|
||||||
|
const allGroups = groups.length === 0 || groups.length === ALL_GROUPS.length
|
||||||
|
return {
|
||||||
|
status: allGroups ? [] : statusValuesFor(groups),
|
||||||
|
type: typeFilter.types || [],
|
||||||
|
excludeType: typeFilter.excludeType,
|
||||||
|
priority: urgentOnly ? [3, 4] : [],
|
||||||
|
search: search.trim() || undefined,
|
||||||
|
woid: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SELECTION = { search: '', typeId: 'orders', groups: DEFAULT_STATUS_GROUPS, urgentOnly: false }
|
||||||
|
|
||||||
export default function TicketsPage() {
|
export default function TicketsPage() {
|
||||||
const [limit, setLimit] = useState(10)
|
const [limit, setLimit] = useState(10)
|
||||||
const [filters, setFilters] = useState({ status: DEFAULT_STATUSES, type: [], priority: [], limit: 10 })
|
const [filters, setFilters] = useState({ ...toFilters(DEFAULT_SELECTION), limit: 10 })
|
||||||
const [statusSel, setStatusSel] = useState(DEFAULT_STATUSES)
|
const [searchText, setSearchText] = useState('')
|
||||||
const [localFilters, setLocalFilters] = useState({
|
const [typeId, setTypeId] = useState(DEFAULT_SELECTION.typeId)
|
||||||
woid: '', customer: '', userTopic: '', createdDate: '', type: '', priority: '',
|
const [groups, setGroups] = useState(DEFAULT_SELECTION.groups)
|
||||||
})
|
const [urgentOnly, setUrgentOnly] = useState(false)
|
||||||
|
|
||||||
const toggleStatus = (status) => {
|
const toggleGroup = (id) => {
|
||||||
setStatusSel((prev) =>
|
setGroups((prev) => (prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id]))
|
||||||
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { workorders, loading, error, refresh, updateWorkorder, createWorkorder } = useWorkorders(filters)
|
const { workorders, loading, error, updateWorkorder, createWorkorder } = useWorkorders(filters)
|
||||||
const { customers } = useCustomers()
|
const { customers } = useCustomers()
|
||||||
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
// Deep-Link aus den Leads: /tickets?woid=123 -> Filter vorbelegen + anwenden
|
// Deep-Link aus den Leads: /tickets?woid=123 zeigt genau dieses Ticket,
|
||||||
// (ueber ALLE Status, damit das Ticket unabhaengig vom Status gefunden wird)
|
// egal welche Art (auch Akquise) und welcher Status
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const w = searchParams.get('woid')
|
const w = searchParams.get('woid')
|
||||||
if (!w) return
|
if (!w) return
|
||||||
setLocalFilters((p) => ({ ...p, woid: w }))
|
setSearchText(w)
|
||||||
setShowAdvanced(true)
|
setTypeId('all')
|
||||||
setStatusSel(ALL_STATUSES)
|
setGroups(ALL_GROUPS)
|
||||||
setFilters((prev) => ({ ...prev, status: ALL_STATUSES, woid: w }))
|
setFilters((prev) => ({ ...prev, status: [], type: [], excludeType: undefined, priority: [], search: undefined, woid: w }))
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
const applyFilters = () => {
|
const applyFilters = (e) => {
|
||||||
setFilters((prev) => ({
|
e?.preventDefault()
|
||||||
...prev,
|
setFilters((prev) => ({ ...prev, ...toFilters({ search: searchText, typeId, groups, urgentOnly }), limit }))
|
||||||
status: statusSel.length ? statusSel : ALL_STATUSES,
|
|
||||||
woid: localFilters.woid || undefined,
|
|
||||||
customer: localFilters.customer || undefined,
|
|
||||||
userTopic: localFilters.userTopic || undefined,
|
|
||||||
createdDate: localFilters.createdDate || undefined,
|
|
||||||
type: localFilters.type ? [localFilters.type] : [],
|
|
||||||
priority: localFilters.priority ? [parseInt(localFilters.priority)] : [],
|
|
||||||
limit,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async (id, data) => { await updateWorkorder(id, data) }
|
const handleUpdate = async (id, data) => { await updateWorkorder(id, data) }
|
||||||
@@ -77,7 +79,7 @@ export default function TicketsPage() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Tickets"
|
title="Tickets"
|
||||||
subtitle="Alle Arbeitsauftraege auf einen Blick"
|
subtitle="Alle Arbeitsaufträge auf einen Blick"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={() => setShowAdvanced((s) => !s)}><FaSliders /> Filter</Button>
|
<Button variant="ghost" onClick={() => setShowAdvanced((s) => !s)}><FaSliders /> Filter</Button>
|
||||||
@@ -87,51 +89,45 @@ export default function TicketsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Card pad className="mb-2" style={{ marginBottom: 16 }}>
|
<Card pad className="mb-2" style={{ marginBottom: 16 }}>
|
||||||
<div className="toolbar" style={{ marginBottom: showAdvanced ? 16 : 0 }}>
|
<form className="toolbar" onSubmit={applyFilters} style={{ marginBottom: showAdvanced ? 16 : 0 }}>
|
||||||
<input className="form-control" style={{ maxWidth: 160 }} placeholder="WOID" value={localFilters.woid} onChange={(e) => setLocalFilters({ ...localFilters, woid: e.target.value })} />
|
<input
|
||||||
<input className="form-control" style={{ maxWidth: 220 }} placeholder="Kunde" value={localFilters.customer} onChange={(e) => setLocalFilters({ ...localFilters, customer: e.target.value })} />
|
type="search"
|
||||||
<input className="form-control search-input" placeholder="Benutzer / Thema" value={localFilters.userTopic} onChange={(e) => setLocalFilters({ ...localFilters, userTopic: e.target.value })} />
|
className="form-control search-input"
|
||||||
<Button variant="primary" onClick={applyFilters}>Anwenden</Button>
|
placeholder="Suchen: Kunde, Betreff, Angefragt von oder WOID"
|
||||||
</div>
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button variant="primary" type="submit">Anwenden</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
{showAdvanced && (
|
{showAdvanced && (
|
||||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 16 }}>
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 16 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12 }}>
|
<div className="flex gap-3 wrap" style={{ alignItems: 'center' }}>
|
||||||
<input type="date" className="form-control" value={localFilters.createdDate} onChange={(e) => setLocalFilters({ ...localFilters, createdDate: e.target.value })} />
|
<select aria-label="Typ" className="form-control" style={{ maxWidth: 220 }} value={typeId} onChange={(e) => setTypeId(e.target.value)}>
|
||||||
<select className="form-control" value={localFilters.type} onChange={(e) => setLocalFilters({ ...localFilters, type: e.target.value })}>
|
{TYPE_FILTERS.map((filter) => (
|
||||||
<option value="">Typ / Ort</option>
|
<option key={filter.id} value={filter.id}>{filter.label}</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>
|
|
||||||
</select>
|
|
||||||
<select className="form-control" value={localFilters.priority} onChange={(e) => setLocalFilters({ ...localFilters, priority: e.target.value })}>
|
|
||||||
<option value="">Prioritaet</option>
|
|
||||||
<option value="0">Keine</option><option value="1">Niedrig</option><option value="2">Mittel</option>
|
|
||||||
<option value="3">Hoch</option><option value="4">Kritisch</option>
|
|
||||||
</select>
|
</select>
|
||||||
|
<label className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 14 }}>
|
||||||
|
<input type="checkbox" checked={urgentOnly} onChange={(e) => setUrgentOnly(e.target.checked)} />
|
||||||
|
Nur dringende
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<div className="flex gap-2 wrap" style={{ alignItems: 'center', marginBottom: 8 }}>
|
<div className="flex gap-2 wrap status-filter-actions" style={{ alignItems: 'center', marginBottom: 8 }}>
|
||||||
<span className="muted" style={{ fontSize: 13, fontWeight: 600 }}>Status</span>
|
<span className="muted" style={{ fontSize: 13, fontWeight: 600 }}>Status</span>
|
||||||
<button type="button" onClick={() => setStatusSel(DEFAULT_STATUSES)} style={{ fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 }}>Standard</button>
|
<button type="button" onClick={() => setGroups(DEFAULT_STATUS_GROUPS)} style={linkButton}>Standard</button>
|
||||||
<button type="button" onClick={() => setStatusSel(ALL_STATUSES)} style={{ fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 }}>Alle inkl. Archiv</button>
|
<button type="button" onClick={() => setGroups(ALL_GROUPS)} style={linkButton}>Alle</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 wrap">
|
<div className="flex gap-3 wrap status-filter">
|
||||||
{ALL_STATUSES.map((status) => (
|
{STATUS_GROUPS.map((group) => (
|
||||||
<label key={status} className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 14 }}>
|
<label key={group.id} className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 14 }}>
|
||||||
<input type="checkbox" checked={statusSel.includes(status)} onChange={() => toggleStatus(status)} />
|
<input type="checkbox" checked={groups.includes(group.id)} onChange={() => toggleGroup(group.id)} />
|
||||||
{status}
|
{group.label}
|
||||||
{ARCHIVED_STATUSES.includes(status) && <span className="faint" style={{ fontSize: 11 }}>(Archiv)</span>}
|
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 wrap" style={{ marginTop: 12 }}>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, priority: [4] })); setTimeout(refresh, 0) }}>Kritische</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, priority: [3] })); setTimeout(refresh, 0) }}>Hohe</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, type: ['Procurement'] })); setTimeout(refresh, 0) }}>Beschaffung</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -141,7 +137,13 @@ export default function TicketsPage() {
|
|||||||
) : error ? (
|
) : error ? (
|
||||||
<Card><div className="text-red">Fehler: {error}</div></Card>
|
<Card><div className="text-red">Fehler: {error}</div></Card>
|
||||||
) : workorders.length === 0 ? (
|
) : workorders.length === 0 ? (
|
||||||
<Card><EmptyState title="Keine Tickets" hint="Keine Tickets passen zu deinen Filtern." action={<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>} /></Card>
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
title="Keine Tickets"
|
||||||
|
hint="Keine Tickets passen zu Suche und Filter. Erledigte, stornierte und Akquise-Tickets sind in der Standardansicht ausgeblendet."
|
||||||
|
action={<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="tickets-list">
|
<div className="tickets-list">
|
||||||
{workorders.map((ticket) => (
|
{workorders.map((ticket) => (
|
||||||
@@ -150,7 +152,8 @@ export default function TicketsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{workorders.length > 0 && workorders.length >= limit && (
|
{/* Bei einer Suche kommen alle Treffer auf einmal */}
|
||||||
|
{!filters.search && workorders.length > 0 && workorders.length >= limit && (
|
||||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||||
<Button variant="ghost" onClick={loadMore}>Mehr laden <FaAngleDown /></Button>
|
<Button variant="ghost" onClick={loadMore}>Mehr laden <FaAngleDown /></Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -498,6 +498,7 @@ textarea.form-control {
|
|||||||
.status-occupied { background: rgba(148,163,184,0.18) !important; color: #cbd5e0 !important; }
|
.status-occupied { background: rgba(148,163,184,0.18) !important; color: #cbd5e0 !important; }
|
||||||
.status-assigned { background: rgba(20,184,166,0.18) !important; color: #2dd4bf !important; }
|
.status-assigned { background: rgba(20,184,166,0.18) !important; color: #2dd4bf !important; }
|
||||||
.status-awaiting { background: rgba(245,158,11,0.18) !important; color: #fbbf24 !important; }
|
.status-awaiting { background: rgba(245,158,11,0.18) !important; color: #fbbf24 !important; }
|
||||||
|
.status-working { background: rgba(59,130,246,0.16) !important; color: #93c5fd !important; }
|
||||||
.status-closed { background: rgba(148,163,184,0.14) !important; color: #94a3b8 !important; }
|
.status-closed { background: rgba(148,163,184,0.14) !important; color: #94a3b8 !important; }
|
||||||
|
|
||||||
/* Priority Colors (ruhiger, semantisch) */
|
/* Priority Colors (ruhiger, semantisch) */
|
||||||
@@ -1052,6 +1053,24 @@ textarea.form-control {
|
|||||||
.tcard-loc { color: var(--text-muted); font-size: 13px; }
|
.tcard-loc { color: var(--text-muted); font-size: 13px; }
|
||||||
.tcard-topic { color: var(--text-muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.tcard-topic { color: var(--text-muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.tcard-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
.tcard-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
||||||
|
|
||||||
|
/* Schalter "Dringend" in der Ticketkarte */
|
||||||
|
/* Aus: dezent gestrichelt, damit nicht jedes Ticket "dringend" wirkt */
|
||||||
|
.urgent-toggle {
|
||||||
|
background: transparent; border: 1px dashed var(--border-strong); color: var(--text-faint);
|
||||||
|
border-radius: var(--r-pill); padding: 3px 12px; font-size: 12px; font-weight: 700; cursor: pointer;
|
||||||
|
}
|
||||||
|
.urgent-toggle:hover { color: var(--text-muted); border-color: rgba(239,68,68,0.5); }
|
||||||
|
.urgent-toggle.on { background: rgba(239,68,68,0.18); border: 1px solid rgba(239,68,68,0.5); color: #f87171; }
|
||||||
|
|
||||||
|
/* Eine Zeile ueber der Arbeitsblatt-Liste */
|
||||||
|
.worksheet-summary {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px 20px; align-items: baseline;
|
||||||
|
padding: 10px 14px; margin-bottom: 16px; font-size: 14px; color: var(--text-muted);
|
||||||
|
background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-md);
|
||||||
|
}
|
||||||
|
.worksheet-summary strong { color: var(--text); }
|
||||||
|
.worksheet-summary .open { color: #fb923c; font-weight: 700; }
|
||||||
.tcard-side { display: flex; align-items: center; gap: var(--sp-3); }
|
.tcard-side { display: flex; align-items: center; gap: var(--sp-3); }
|
||||||
.tcard-detail { border-top: 1px solid var(--border); padding: var(--sp-4); background: var(--surface-1); }
|
.tcard-detail { border-top: 1px solid var(--border); padding: var(--sp-4); background: var(--surface-1); }
|
||||||
.chevron { color: var(--text-muted); transition: transform 0.2s ease; }
|
.chevron { color: var(--text-muted); transition: transform 0.2s ease; }
|
||||||
|
|||||||
Reference in new issue
Block a user