Kunden: - Neues Feld customers.archived; Archivieren/Wiederherstellen-Button in der Kundendetailseite, 'Archiviert'-Badge - CustomersPage: vierter Filter-Tab 'Archiviert'; archivierte Kunden aus Alle/Leads/Feste Kunden ausgeblendet, nur im Archiv-Tab sichtbar Tickets: - Status-Mehrfachauswahl per Checkbox im Filterbereich; Default alle Status ausser 'Closed' (= archiviert). 'Standard' / 'Alle inkl. Archiv' Shortcuts - useWorkorders: Statusfilter serverseitig via Query.equal(array) statt Client-Filter nach dem Limit (behebt Bug, bei dem archivierte Tickets die neuesten Slots belegten und aktive verdraengten) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
380 lines
13 KiB
JavaScript
380 lines
13 KiB
JavaScript
import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
import { databases, DATABASE_ID, COLLECTIONS, Query, ID, isDemoMode } from '../lib/appwrite'
|
|
|
|
const DEMO_MODE = isDemoMode
|
|
|
|
function buildFiltersKey(filters = {}) {
|
|
return JSON.stringify({
|
|
limit: filters.limit ?? null,
|
|
customerId: filters.customerId ?? null,
|
|
assignedTo: filters.assignedTo ?? null,
|
|
status: filters.status ?? null,
|
|
type: filters.type ?? null,
|
|
priority: filters.priority ?? null,
|
|
})
|
|
}
|
|
|
|
// Demo data for testing without Appwrite
|
|
const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
|
|
|
const DEMO_WORKORDERS = [
|
|
{
|
|
$id: 'dummy-10001',
|
|
woid: '10001',
|
|
topic: 'Kompletter Systemausfall - Server & Netzwerk',
|
|
status: 'Assigned',
|
|
priority: 4,
|
|
type: 'Emergency Call',
|
|
systemType: 'Server',
|
|
responseLevel: 'Backoffice',
|
|
serviceType: 'On Site',
|
|
customerName: 'Kunde A',
|
|
customerLocation: 'Hauptstraße 123, 12345 Musterstadt',
|
|
assignedTo: 'user-max-id',
|
|
assignedName: 'Max Mustermann',
|
|
requestedBy: 'Dr. Anna Schmidt',
|
|
requestedFor: 'IT-Abteilung Kunde A',
|
|
startDate: '30.12.2025',
|
|
startTime: '0800',
|
|
deadline: '31.12.2025',
|
|
endTime: '1800',
|
|
estimate: '480',
|
|
mailCopyTo: 'admin@kunde-a.de, it@kunde-a.de',
|
|
sendNotification: true,
|
|
details: `KRITISCHER SYSTEMAUSFALL - SOFORTIGE BEARBEITUNG ERFORDERLICH
|
|
|
|
Problembeschreibung:
|
|
- Kompletter Serverausfall im Rechenzentrum
|
|
- Alle Server sind offline (keine Verbindung möglich)
|
|
- Netzwerk-Infrastruktur betroffen
|
|
- Keine Backup-Systeme verfügbar
|
|
|
|
Betroffene Systeme:
|
|
- Hauptserver (Windows Server 2022)
|
|
- Datenbankserver (SQL Server 2019)
|
|
- Fileserver
|
|
- Exchange Server
|
|
- Netzwerk-Switches
|
|
|
|
Auswirkungen:
|
|
- Keine E-Mail-Kommunikation möglich
|
|
- Alle Anwendungen offline
|
|
- Kein Zugriff auf Datenbanken
|
|
- Produktion steht still
|
|
|
|
Dringlichkeit: KRITISCH - Produktionsausfall
|
|
|
|
Erwartete Bearbeitungszeit: 8 Stunden
|
|
Benötigte Ressourcen: 2 Techniker, Hardware-Ersatzteile`,
|
|
approvalStatus: 'approved',
|
|
$createdAt: lastWeek.toISOString(),
|
|
createdAt: lastWeek.toISOString()
|
|
},
|
|
{ $id: '2', woid: '10002', title: 'Netzwerk Problem', description: 'WLAN funktioniert nicht', status: 'Occupied', priority: 3, type: 'Support', customerName: 'Kunde B', assignedName: 'Lisa S.', response: 'Remote', $createdAt: new Date().toISOString() },
|
|
{ $id: '3', woid: '10003', title: 'Software Installation', description: 'Office 365 Setup', status: 'Assigned', priority: 1, type: 'Installation', customerName: 'Kunde C', assignedName: 'Tom K.', response: 'Onsite', $createdAt: new Date().toISOString() },
|
|
{ $id: '4', woid: '10004', title: 'Drucker defekt', description: 'Papierstau', status: 'Awaiting', priority: 2, type: 'Hardware', customerName: 'Kunde D', assignedName: '', response: 'Pickup', $createdAt: new Date().toISOString() },
|
|
{ $id: '5', woid: '10005', title: 'Kritischer Serverausfall', description: 'Produktionsserver down', status: 'Open', priority: 4, type: 'Emergency', customerName: 'Kunde E', assignedName: 'Max M.', response: 'Onsite', $createdAt: new Date().toISOString() },
|
|
]
|
|
|
|
export function useWorkorders(filters = {}) {
|
|
const [workorders, setWorkorders] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState(null)
|
|
const filtersKey = useMemo(() => buildFiltersKey(filters), [
|
|
filters.limit,
|
|
filters.customerId,
|
|
filters.assignedTo,
|
|
filters.status,
|
|
filters.type,
|
|
filters.priority,
|
|
])
|
|
|
|
const fetchWorkorders = useCallback(async () => {
|
|
const activeFilters = JSON.parse(filtersKey)
|
|
setLoading(true)
|
|
|
|
if (DEMO_MODE) {
|
|
// Filter demo data
|
|
let filtered = [...DEMO_WORKORDERS]
|
|
if (activeFilters.status?.length > 0) {
|
|
filtered = filtered.filter(wo => activeFilters.status.includes(wo.status))
|
|
}
|
|
if (activeFilters.priority?.length > 0) {
|
|
filtered = filtered.filter(wo => activeFilters.priority.includes(wo.priority))
|
|
}
|
|
if (activeFilters.limit) {
|
|
filtered = filtered.slice(0, activeFilters.limit)
|
|
}
|
|
setWorkorders(filtered)
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
try {
|
|
const queries = [Query.orderDesc('$createdAt')]
|
|
|
|
if (activeFilters.limit) {
|
|
queries.push(Query.limit(activeFilters.limit))
|
|
}
|
|
|
|
// Appwrite Query.equal mit Array = OR-Match. Serverseitig filtern, damit
|
|
// das Limit erst NACH dem Statusfilter greift (sonst wuerden archivierte
|
|
// Tickets die neuesten Slots belegen und aktive verdraengen).
|
|
if (activeFilters.status && activeFilters.status.length > 0) {
|
|
queries.push(Query.equal('status', activeFilters.status))
|
|
}
|
|
|
|
if (activeFilters.type && activeFilters.type.length > 0) {
|
|
queries.push(Query.equal('type', activeFilters.type))
|
|
}
|
|
|
|
if (activeFilters.priority && activeFilters.priority.length > 0) {
|
|
queries.push(Query.equal('priority', activeFilters.priority))
|
|
}
|
|
|
|
if (activeFilters.customerId) {
|
|
queries.push(Query.equal('customerId', activeFilters.customerId))
|
|
}
|
|
|
|
if (activeFilters.assignedTo) {
|
|
queries.push(Query.equal('assignedTo', activeFilters.assignedTo))
|
|
}
|
|
|
|
// Debug: Zeige Collection ID
|
|
if (import.meta.env.DEV) {
|
|
console.log('📋 Fetching workorders:')
|
|
console.log(' Database ID:', DATABASE_ID)
|
|
console.log(' Collection ID:', COLLECTIONS.WORKORDERS)
|
|
console.log(' Queries:', queries.length)
|
|
}
|
|
|
|
const response = await databases.listDocuments(
|
|
DATABASE_ID,
|
|
COLLECTIONS.WORKORDERS,
|
|
queries
|
|
)
|
|
|
|
setWorkorders(response.documents)
|
|
setError(null)
|
|
} catch (err) {
|
|
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
|
|
|
|
// Bessere Fehlermeldungen
|
|
if (err.code === 401 || errorMessage.includes('not authorized') || errorMessage.includes('Unauthorized')) {
|
|
errorMessage = 'Berechtigung fehlt: Bitte überprüfe die Read-Berechtigungen der Collection in Appwrite. Die Collection muss "Users" oder "Any" als Read-Berechtigung haben.'
|
|
} else if (errorMessage.includes('Collection') && errorMessage.includes('not found')) {
|
|
errorMessage = 'Collection nicht gefunden: Bitte überprüfe die Collection ID in der Konfiguration.'
|
|
}
|
|
|
|
setError(errorMessage)
|
|
console.error('Error fetching workorders:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [filtersKey])
|
|
|
|
useEffect(() => {
|
|
fetchWorkorders()
|
|
}, [fetchWorkorders])
|
|
|
|
const resolveAutomaticTicketFields = async (requestedAssignee = '') => {
|
|
const [allTicketsResponse, employeesResponse] = await Promise.all([
|
|
databases.listDocuments(
|
|
DATABASE_ID,
|
|
COLLECTIONS.WORKORDERS,
|
|
[Query.limit(5000)]
|
|
),
|
|
databases.listDocuments(
|
|
DATABASE_ID,
|
|
COLLECTIONS.EMPLOYEES,
|
|
[Query.orderAsc('displayName'), Query.limit(500)]
|
|
),
|
|
])
|
|
|
|
const allTickets = allTicketsResponse.documents || []
|
|
const employees = employeesResponse.documents || []
|
|
const maxWoid = allTickets.reduce((highest, ticket) => {
|
|
const numericId = Number.parseInt(ticket.woid, 10)
|
|
return Number.isFinite(numericId) ? Math.max(highest, numericId) : highest
|
|
}, 9999)
|
|
|
|
let assignee = requestedAssignee
|
|
? employees.find((employee) => employee.userId === requestedAssignee)
|
|
: null
|
|
|
|
if (!assignee && employees.length > 0) {
|
|
const finishedStatuses = new Set(['Closed', 'Completed', 'Cancelled', 'Done'])
|
|
const activeCounts = new Map(employees.map((employee) => [employee.userId, 0]))
|
|
for (const ticket of allTickets) {
|
|
if (!ticket.assignedTo || finishedStatuses.has(ticket.status)) continue
|
|
if (activeCounts.has(ticket.assignedTo)) {
|
|
activeCounts.set(ticket.assignedTo, activeCounts.get(ticket.assignedTo) + 1)
|
|
}
|
|
}
|
|
assignee = [...employees].sort((left, right) => {
|
|
const loadDifference = activeCounts.get(left.userId) - activeCounts.get(right.userId)
|
|
if (loadDifference !== 0) return loadDifference
|
|
return (left.displayName || '').localeCompare(right.displayName || '', 'de')
|
|
})[0]
|
|
}
|
|
|
|
return {
|
|
woid: String(maxWoid + 1),
|
|
assignedTo: assignee?.userId || '',
|
|
assignedName: assignee?.displayName || '',
|
|
}
|
|
}
|
|
|
|
const createWorkorder = async (data) => {
|
|
if (DEMO_MODE) {
|
|
// Finde höchste WOID und +1
|
|
const maxWoid = workorders.length > 0
|
|
? Math.max(...workorders.map(wo => parseInt(wo.woid)).filter(w => !isNaN(w)))
|
|
: 9999;
|
|
const nextWoid = maxWoid + 1;
|
|
|
|
const newWo = { ...data, $id: Date.now().toString(), woid: nextWoid.toString(), status: 'Open', $createdAt: new Date().toISOString() }
|
|
setWorkorders(prev => [newWo, ...prev])
|
|
return { success: true, data: newWo }
|
|
}
|
|
|
|
try {
|
|
// Validierung: Prüfe required fields
|
|
if (!data.topic || data.topic.trim() === '') {
|
|
return { success: false, error: 'Das Feld "Topic" ist erforderlich.' }
|
|
}
|
|
|
|
const automatic = await resolveAutomaticTicketFields(data.assignedTo)
|
|
const autoStatus = automatic.assignedTo ? 'Assigned' : 'Open'
|
|
|
|
// Bereite Daten für Appwrite vor
|
|
const workorderData = {
|
|
// Required fields
|
|
topic: data.topic.trim(),
|
|
status: autoStatus,
|
|
priority: typeof data.priority === 'number' ? data.priority : parseInt(data.priority) || 1,
|
|
woid: automatic.woid,
|
|
|
|
// Optional fields - nur senden wenn vorhanden
|
|
type: data.type || '',
|
|
systemType: data.systemType || '',
|
|
responseLevel: data.responseLevel || '',
|
|
serviceType: data.serviceType || 'Remote',
|
|
customerId: data.customerId || '',
|
|
assignedTo: automatic.assignedTo,
|
|
assignedName: automatic.assignedName,
|
|
requestedBy: data.requestedBy || '',
|
|
requestedFor: data.requestedFor || '',
|
|
startDate: data.startDate || '',
|
|
startTime: data.startTime || '',
|
|
deadline: data.deadline || '',
|
|
endTime: data.endTime || '',
|
|
estimate: data.estimate || '',
|
|
mailCopyTo: data.mailCopyTo || '',
|
|
sendNotification: data.sendNotification || false,
|
|
details: data.details || '',
|
|
|
|
// Datetime field
|
|
createdAt: new Date().toISOString()
|
|
}
|
|
|
|
// Entferne leere Strings (außer für required fields)
|
|
Object.keys(workorderData).forEach(key => {
|
|
if (workorderData[key] === '' && key !== 'topic' && key !== 'status') {
|
|
delete workorderData[key]
|
|
}
|
|
})
|
|
|
|
console.log('Creating workorder with data:', workorderData)
|
|
|
|
const response = await databases.createDocument(
|
|
DATABASE_ID,
|
|
COLLECTIONS.WORKORDERS,
|
|
ID.unique(),
|
|
workorderData
|
|
)
|
|
setWorkorders(prev => [response, ...prev])
|
|
return { success: true, data: response }
|
|
} catch (err) {
|
|
console.error('Error creating workorder:', err)
|
|
let errorMessage = err.message || 'Fehler beim Erstellen des Tickets'
|
|
|
|
// Bessere Fehlermeldungen
|
|
if (err.code === 400 || errorMessage.includes('Bad Request')) {
|
|
errorMessage = 'Ungültige Daten: Bitte überprüfe, ob alle Pflichtfelder ausgefüllt sind und die Daten korrekt sind. Details: ' + (err.message || 'Unbekannter Fehler')
|
|
} else if (errorMessage.includes('required') || errorMessage.includes('missing')) {
|
|
errorMessage = 'Pflichtfelder fehlen: Bitte fülle alle erforderlichen Felder aus.'
|
|
}
|
|
|
|
return { success: false, error: errorMessage }
|
|
}
|
|
}
|
|
|
|
const updateWorkorder = async (id, data) => {
|
|
if (DEMO_MODE) {
|
|
setWorkorders(prev => prev.map(wo => wo.$id === id ? { ...wo, ...data } : wo))
|
|
return { success: true }
|
|
}
|
|
|
|
try {
|
|
// Status-Automatik beim Update:
|
|
// 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(
|
|
DATABASE_ID,
|
|
COLLECTIONS.WORKORDERS,
|
|
id,
|
|
updateData
|
|
)
|
|
setWorkorders(prev =>
|
|
prev.map(wo => wo.$id === id ? response : wo)
|
|
)
|
|
return { success: true, data: response }
|
|
} catch (err) {
|
|
return { success: false, error: err.message }
|
|
}
|
|
}
|
|
|
|
const deleteWorkorder = async (id) => {
|
|
if (DEMO_MODE) {
|
|
setWorkorders(prev => prev.filter(wo => wo.$id !== id))
|
|
return { success: true }
|
|
}
|
|
|
|
try {
|
|
await databases.deleteDocument(
|
|
DATABASE_ID,
|
|
COLLECTIONS.WORKORDERS,
|
|
id
|
|
)
|
|
setWorkorders(prev => prev.filter(wo => wo.$id !== id))
|
|
return { success: true }
|
|
} catch (err) {
|
|
return { success: false, error: err.message }
|
|
}
|
|
}
|
|
|
|
return {
|
|
workorders,
|
|
loading,
|
|
error,
|
|
refresh: fetchWorkorders,
|
|
createWorkorder,
|
|
updateWorkorder,
|
|
deleteWorkorder
|
|
}
|
|
}
|