diff --git a/src/App.jsx b/src/App.jsx index 74f0ad0..23fd9d1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,6 +5,7 @@ import Sidebar from './components/Navbar' import Logo from './components/Logo' import LoginPage from './pages/LoginPage' import TicketsPage from './pages/TicketsPage' +import TicketPage from './pages/TicketPage' import PlanboardPage from './pages/PlanboardPage' import ProjectsPage from './pages/ProjectsPage' import AdminPage from './pages/AdminPage' @@ -48,6 +49,7 @@ function AppRoutes() { : } /> } /> } /> + } /> } /> {/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */} } /> @@ -87,10 +89,13 @@ function AppContent() { window.scrollTo(0, 0) }, [location.pathname]) - // Esc schliesst die Schublade, nur solange sie offen ist + // Esc schliesst die Schublade, nur solange sie offen ist und nur, wenn kein anderer Baustein die Taste schon behandelt hat useEffect(() => { if (!menuOpen) return undefined - const onKey = (e) => { if (e.key === 'Escape') setMenuOpen(false) } + const onKey = (e) => { + if (e.defaultPrevented) return + if (e.key === 'Escape') setMenuOpen(false) + } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [menuOpen]) diff --git a/src/pages/TicketPage.jsx b/src/pages/TicketPage.jsx new file mode 100644 index 0000000..424d274 --- /dev/null +++ b/src/pages/TicketPage.jsx @@ -0,0 +1,296 @@ +import { useMemo, useState } from 'react' +import { Link, Navigate, useLocation, useNavigate, useParams } from 'react-router-dom' +import { PageHeader, Tabs, Card, EmptyState, Button } from '../components/ui' +import UrgentToggle from '../components/UrgentToggle' +import WorksheetSummary from '../components/WorksheetSummary' +import WorksheetList from '../components/WorksheetList' +import ProjectContextPanel from '../components/ProjectContextPanel' +import TicketProjects from '../components/TicketProjects' +import CreateWorksheetModal from '../components/CreateWorksheetModal' +import WorksheetTimesModal from '../components/WorksheetTimesModal' +import OwnSupportModal from '../components/OwnSupportModal' +import { useAuth } from '../context/AuthContext' +import { useEmployees } from '../hooks/useEmployees' +import { useWorkorder } from '../hooks/useWorkorder' +import { useWorksheets } from '../hooks/useWorksheets' +import { useCustomerAbo } from '../hooks/useCustomerAbo' +import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite' +import { elapsedLabel } from '../lib/elapsed' +import { canAddOwnSupport } from '../lib/support' +import { canEditWorksheetTimes, serviceTypeLabel } from '../lib/ticketForm' +import { listBackPath, resolveTicketTab, ticketPath, ticketRef } from '../lib/ticketRoutes' +import { statusSelectOptions, ticketTypeLabel } from '../lib/ticketStatus' +import { appointmentText, assigneeOptions, dueText, ticketNumber } from '../lib/ticketView' +import { createWorksheetFlow } from '../lib/worksheetFlow' + +// Ein Ticket als eigene Seite: /tickets/ (Details) und /tickets//arbeitsblaetter. +// Alle Hooks stehen vor jeder Weiterleitung, sonst bricht der Wechsel /tickets/5/xyz -> /tickets/5 die Hook-Reihenfolge. +export default function TicketPage() { + const { ref, tab } = useParams() + const location = useLocation() + const { ticket, loading, error, notFound, updateWorkorder } = useWorkorder(ref) + + const activeTab = resolveTicketTab(tab) + if (activeTab === null) return + + const back = + + if (loading) { + return ( +
+
Wird geladen …
+
+ ) + } + + if (notFound) { + return ( +
+ +
+ ) + } + + if (error || !ticket) { + return ( +
+

Fehler: {error || 'Das Ticket konnte nicht geladen werden.'}

+
{back}
+
+ ) + } + + return +} + +function TicketView({ ticket, updateWorkorder, tab }) { + const location = useLocation() + const navigate = useNavigate() + const { user, employee } = useAuth() + // Einmal laden und an die Dialoge reichen (der Zuständig-Filter und die Unterstützer-Liste teilen sich die Antwort) + const { employees, loading: employeesLoading, error: employeesError } = useEmployees() + const employeesState = useMemo( + () => ({ employees, loading: employeesLoading, error: employeesError }), + [employees, employeesLoading, employeesError] + ) + const [showCreateWorksheet, setShowCreateWorksheet] = useState(false) + const [editTimesWorksheet, setEditTimesWorksheet] = useState(null) + const [ownSupportWorksheet, setOwnSupportWorksheet] = useState(null) + // Werte, die gerade gespeichert werden: das Feld zeigt den neuen Wert und ist gesperrt, bei einem Fehler springt es zurück + const [pending, setPending] = useState({}) + + const woid = ticket.woid || null + const { + worksheets, + loading: worksheetsLoading, + error: worksheetsError, + createWorksheet, + updateWorksheet, + updateWorksheetTimes, + saveOwnSupport, + } = useWorksheets(woid) + // Abo-Stundenkonto gibt es nur bei Wartungs-Abo; erst laden, wenn Arbeitsblätter gebraucht werden + const { hoursAllowed: aboHoursAllowed } = useCustomerAbo( + ticket.customerId, + tab === 'arbeitsblaetter' || showCreateWorksheet + ) + + const isPending = (field) => pending[field] !== undefined + const shown = (field, current) => (isPending(field) ? pending[field] : current) + + // Fehler beim Schreiben bleiben still (wie bisher): bei Misserfolg ändert sich das Ticket nicht und das Feld springt zurück + const saveField = async (field, value) => { + if (isPending(field)) return + setPending((prev) => ({ ...prev, [field]: value })) + try { + await updateWorkorder(ticket.$id, { [field]: value }) + } finally { + setPending((prev) => { + const next = { ...prev } + delete next[field] + return next + }) + } + } + + const goToTab = (id) => navigate(ticketPath(ticketRef(ticket), id), { state: location.state }) + + // Abo-Stunden: Arbeitszeit vom Stundenguthaben des Wartungs-Abos abziehen oder nicht + const togglePlan = (ws) => updateWorksheet(ws.$id, { countsToPlan: !ws.countsToPlan }) + + const handleCreateWorksheet = async (worksheetData, currentUser) => { + const result = await createWorksheetFlow({ + ticket, + worksheetData, + user: currentUser, + createWorksheet, + updateTicket: updateWorkorder, + updateCustomer: (id, data) => databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, id, data), + }) + // Das neue Blatt soll sichtbar sein + if (result?.success && tab !== 'arbeitsblaetter') goToTab('arbeitsblaetter') + return result + } + + const number = ticketNumber(ticket) + const customerLabel = ticket.customerName || 'Unbekannt' + const worksheetCount = !woid || worksheetsLoading ? undefined : worksheetsError ? '–' : worksheets.length + const appointment = appointmentText(ticket) + const due = dueText(ticket) + + return ( +
+ + {ticket.customerId ? ( + {customerLabel} + ) : ( + customerLabel + )} + {ticket.customerLocation ? ` · ${ticket.customerLocation}` : ''} + + } + actions={ + <> + saveField('priority', priority)} + disabled={isPending('priority')} + /> + + + + } + /> + +
+
+
+
+ +
+
+
+
Termin
+
{appointment || Kein Termin}
+
+
+
Fällig
+
{due || '–'}
+
+
+
Einsatz
+
{serviceTypeLabel(ticket.serviceType) || '–'}
+
+
+
Angefragt von
+
{ticket.requestedBy || '–'}
+
+
+
Erstellt
+
{elapsedLabel(ticket)}
+
+
+ + { if (id !== tab) goToTab(id) }} + /> + + {tab === 'details' && ( + <> + +

{ticket.details || 'Keine Details vorhanden.'}

+
+ + + + )} + + {tab === 'arbeitsblaetter' && ( + !woid ? ( +

Für dieses Ticket gibt es keine Ticketnummer, Arbeitsblätter sind nicht möglich.

+ ) : worksheetsError ? ( +

{worksheetsError}

+ ) : ( + <> + + canEditWorksheetTimes(ws, user, employee)} + onEditTimes={setEditTimesWorksheet} + canAddSupport={(ws) => canAddOwnSupport(ws, user, employee)} + onOwnSupport={setOwnSupportWorksheet} + currentUserId={user?.$id} + /> + + ) + )} + + setShowCreateWorksheet(false)} + workorder={ticket} + onCreate={handleCreateWorksheet} + aboHoursAllowed={aboHoursAllowed} + employeesState={employeesState} + /> + {ownSupportWorksheet && ( + setOwnSupportWorksheet(null)} + onSave={(ws, task) => saveOwnSupport(ws, task, user, employee)} + /> + )} + {editTimesWorksheet && ( + setEditTimesWorksheet(null)} + onSave={(ws, times) => updateWorksheetTimes(ws, times, user, employee)} + employeesState={employeesState} + /> + )} +
+ ) +}