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:
ClaudeandClaude Opus 5.5 committed 2026-09-27 15:46:03 +00:00
1 parent bf007df737
commit 062d992b63
17 files changed
+359 -626

No files matched your search

+29 -77
View File
@@ -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>
+22 -26
View File
@@ -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>
-36
View File
@@ -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>
)
}
+7 -18
View File
@@ -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>
+18 -29
View File
@@ -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>
)} )}
+17
View File
@@ -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>
)
}
+5 -4
View File
@@ -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>
)} )}
-313
View File
@@ -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>
)
}
+24
View File
@@ -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>
)
}
+6 -21
View File
@@ -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>
} }
+1 -1
View File
@@ -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
View File
@@ -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)
+105
View File
@@ -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))
}
+2 -2
View File
@@ -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>
))} ))}
+2 -2
View File
@@ -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
View File
@@ -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>
+19
View File
@@ -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; }