diff --git a/src/hooks/useTicketCounts.js b/src/hooks/useTicketCounts.js new file mode 100644 index 0000000..a39ddcf --- /dev/null +++ b/src/hooks/useTicketCounts.js @@ -0,0 +1,51 @@ +import { useState, useEffect } from 'react' +import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite' +import { LIST_TABS, LIST_DEFAULTS, countFilters } from '../lib/ticketListUrl' + +// Zahl je Reiter der Ticketliste: counts[tabId] ist eine Zahl, null bei Fehler, +// vor der ersten Antwort fehlt der Schluessel. Die Zahlen haengen nur an Art und "Dringend", +// nie am Reiter oder der Suche. Alte Zahlen bleiben stehen, bis die neue Antwort da ist. +function countQueries(filters) { + // Dieselben Bedingungen wie in useWorkorders, nur mit limit(1): gebraucht wird allein "total" + const queries = [Query.limit(1)] + if (filters.status && filters.status.length > 0) queries.push(Query.equal('status', filters.status)) + if (filters.type && filters.type.length > 0) queries.push(Query.equal('type', filters.type)) + if (filters.excludeType) queries.push(Query.notEqual('type', filters.excludeType)) + if (filters.priority && filters.priority.length > 0) queries.push(Query.equal('priority', filters.priority)) + return queries +} + +export function useTicketCounts({ typeId, urgentOnly } = {}) { + const [counts, setCounts] = useState({}) + + useEffect(() => { + let stale = false + const state = { ...LIST_DEFAULTS, typeId, urgentOnly } + + Promise.allSettled( + LIST_TABS.map((tab) => databases.listDocuments( + DATABASE_ID, + COLLECTIONS.WORKORDERS, + countQueries(countFilters(state, tab.id)) + )) + ).then((results) => { + if (stale) return + + const next = {} + results.forEach((result, index) => { + const tabId = LIST_TABS[index].id + if (result.status === 'fulfilled' && typeof result.value?.total === 'number') { + next[tabId] = result.value.total + } else { + next[tabId] = null + console.warn('Ticket-Zaehler nicht geladen:', tabId, result.reason) + } + }) + setCounts(next) + }) + + return () => { stale = true } + }, [typeId, urgentOnly]) + + return { counts } +} diff --git a/src/hooks/useTicketMinutes.js b/src/hooks/useTicketMinutes.js new file mode 100644 index 0000000..09d3b2b --- /dev/null +++ b/src/hooks/useTicketMinutes.js @@ -0,0 +1,58 @@ +import { useState, useEffect, useRef } from 'react' +import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite' +import { planTimeLoad, blockMinutes, mapLimit, TIME_PARALLEL, TIME_QUERY_LIMIT } from '../lib/ticketTimes' + +// Spalte "Zeit" der Ticketliste: Arbeitszeit je woid, in Bloecken nachgeladen (nur Lesen). +// byWoid ist eine Map woid -> Minuten (null = nicht zu ermitteln). Sie wird an Ort und Stelle +// ergaenzt, `version` zaehlt hoch, wenn sich etwas geaendert hat (fuer useMemo-Abhaengigkeiten). +// Der Zwischenspeicher gilt nur fuer die Lebensdauer der Seite: wer von einer Ticketseite +// zurueckkehrt, sieht frische Zahlen. +export function useTicketMinutes(tickets) { + const cache = useRef(new Map()) + const inFlight = useRef(new Set()) + const mounted = useRef(true) + const [version, setVersion] = useState(0) + const [capped, setCapped] = useState(false) + + useEffect(() => { + mounted.current = true + return () => { mounted.current = false } + }, []) + + useEffect(() => { + const byWoid = cache.current + const running = inFlight.current + // Ein woid, dessen Abfrage noch laeuft, gilt als bekannt (kein Block doppelt anfragen) + const known = { has: (woid) => byWoid.has(woid) || running.has(woid) } + const plan = planTimeLoad(tickets, known) + setCapped(plan.capped) + if (plan.blocks.length === 0) return + + for (const block of plan.blocks) { + for (const woid of block) running.add(woid) + } + + // Auch wenn sich `tickets` inzwischen geaendert hat, wird das Ergebnis eingetragen: + // sonst bliebe das woid ungeladen und ohne Abfrage. + mapLimit(plan.blocks, TIME_PARALLEL, async (block) => { + const response = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKSHEETS, [ + Query.equal('woid', block), + Query.select(['woid', 'totalTime', 'supporters', 'isComment']), + Query.limit(TIME_QUERY_LIMIT), + ]) + return blockMinutes(block, response.documents, response.total) + }).then((results) => { + results.forEach((result, index) => { + const sums = result.ok ? result.value : null + if (!sums) console.warn('Zeitsummen nicht geladen:', result.ok ? 'Antwort abgeschnitten' : result.error) + for (const woid of plan.blocks[index]) { + byWoid.set(woid, sums ? sums[woid] ?? null : null) + running.delete(woid) + } + }) + if (mounted.current) setVersion((v) => v + 1) + }) + }, [tickets]) + + return { byWoid: cache.current, capped, version } +} diff --git a/src/hooks/useWorkorder.js b/src/hooks/useWorkorder.js new file mode 100644 index 0000000..4779791 --- /dev/null +++ b/src/hooks/useWorkorder.js @@ -0,0 +1,89 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite' +import { lookupWorkorder } from '../lib/workorderLookup' +import { createRequestGuard } from '../lib/requestGuard' + +// Ein Ticket zu einer Adresse wie /tickets/ (Nummer oder Dokument-ID). +// Der Zustand merkt sich zu welcher Adresse er gehoert: steht schon eine neue Adresse in der +// URL, zeigt der Hook "laedt" und nie das vorige Ticket. +const LOADING = { ref: undefined, status: 'loading', ticket: null, error: null } + +// Neueste Anfrage zur Nummer (woid), null wenn es keine gibt +async function findByWoid(woid) { + const response = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [ + Query.equal('woid', [String(woid)]), + Query.orderDesc('$createdAt'), + Query.limit(1), + ]) + return response.documents[0] || null +} + +function getById(id) { + return databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, id) +} + +export function useWorkorder(ref) { + const [state, setState] = useState(LOADING) + // Nur die juengste Anfrage darf das Ergebnis uebernehmen + const guard = useRef(createRequestGuard()) + const current = state.ref === ref ? state : LOADING + + const load = useCallback(async () => { + const requestId = guard.current.next() + + try { + const result = await lookupWorkorder(String(ref ?? ''), { findByWoid, getById }) + if (!guard.current.isCurrent(requestId)) return + + setState( + result.status === 'ok' + ? { ref, status: 'ok', ticket: result.ticket, error: null } + : { ref, status: 'notfound', ticket: null, error: null } + ) + } catch (err) { + if (!guard.current.isCurrent(requestId)) return + + let errorMessage = err.message || 'Fehler beim Laden des Tickets' + 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.' + } + + setState({ ref, status: 'error', ticket: null, error: errorMessage }) + console.error('Error fetching workorder:', err) + } + }, [ref]) + + useEffect(() => { + load() + // Wechselt die Adresse oder verschwindet die Seite, zaehlt eine laufende Anfrage nicht mehr + const activeGuard = guard.current + return () => { activeGuard.next() } + }, [load]) + + const updateWorkorder = useCallback(async (id, data) => { + try { + const response = await databases.updateDocument( + DATABASE_ID, + COLLECTIONS.WORKORDERS, + id, + data + ) + // Nur das geladene Ticket ersetzen (nicht, wenn die Seite inzwischen ein anderes zeigt) + setState((prev) => ( + prev.status === 'ok' && prev.ticket?.$id === id ? { ...prev, ticket: response } : prev + )) + return { success: true, data: response } + } catch (err) { + return { success: false, error: err.message } + } + }, []) + + return { + ticket: current.ticket, + loading: current.status === 'loading', + error: current.error, + notFound: current.status === 'notfound', + updateWorkorder, + refresh: load, + } +} diff --git a/src/hooks/useWorkorders.js b/src/hooks/useWorkorders.js index bc3b34f..a5d06ed 100644 --- a/src/hooks/useWorkorders.js +++ b/src/hooks/useWorkorders.js @@ -1,6 +1,7 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite' import { ticketMatches, NORMAL_PRIORITY } from '../lib/ticketStatus' +import { createRequestGuard } from '../lib/requestGuard' function buildFiltersKey(filters = {}) { return JSON.stringify({ @@ -20,6 +21,8 @@ export function useWorkorders(filters = {}) { const [workorders, setWorkorders] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + // Nur die juengste Anfrage darf das Ergebnis uebernehmen + const guard = useRef(createRequestGuard()) const filtersKey = useMemo(() => buildFiltersKey(filters), [ filters.limit, filters.customerId, @@ -34,6 +37,7 @@ export function useWorkorders(filters = {}) { const fetchWorkorders = useCallback(async () => { const activeFilters = JSON.parse(filtersKey) + const requestId = guard.current.next() setLoading(true) try { @@ -92,6 +96,7 @@ export function useWorkorders(filters = {}) { COLLECTIONS.WORKORDERS, queries ) + if (!guard.current.isCurrent(requestId)) return setWorkorders( activeFilters.search @@ -100,6 +105,7 @@ export function useWorkorders(filters = {}) { ) setError(null) } catch (err) { + if (!guard.current.isCurrent(requestId)) return let errorMessage = err.message || 'Fehler beim Laden der Tickets' // Bessere Fehlermeldungen @@ -112,7 +118,7 @@ export function useWorkorders(filters = {}) { setError(errorMessage) console.error('Error fetching workorders:', err) } finally { - setLoading(false) + if (guard.current.isCurrent(requestId)) setLoading(false) } }, [filtersKey])