From 6eb5ab367986dbb31a22e5a2eed2db5a40de4027 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 11:10:46 +0000 Subject: [PATCH] Tickets: Arbeitsblatt-Zeilen (.ws), Kennzahlen, Dringend-Schalter und Weitere Tickets im neuen Look Co-Authored-By: Claude Opus 5.5 --- src/components/ProjectContextPanel.jsx | 50 +-- src/components/UrgentToggle.jsx | 6 +- src/components/WorksheetList.jsx | 431 ++++++------------------- src/components/WorksheetSummary.jsx | 26 +- src/lib/elapsed.js | 9 + src/styles/global.css | 45 +++ 6 files changed, 186 insertions(+), 381 deletions(-) create mode 100644 src/lib/elapsed.js diff --git a/src/components/ProjectContextPanel.jsx b/src/components/ProjectContextPanel.jsx index b7c5a95..1e207c3 100644 --- a/src/components/ProjectContextPanel.jsx +++ b/src/components/ProjectContextPanel.jsx @@ -1,17 +1,19 @@ import { useEffect, useState } from 'react' -import { Link } from 'react-router-dom' -import { IconFolders } from '@tabler/icons-react' +import { Link, useLocation } from 'react-router-dom' +import { Card } from './ui' import { StatusPill } from './ui/StatusPill' import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite' import { useWebsiteProjects } from '../hooks/useWebsiteProjects' +import { ticketPath, ticketRef } from '../lib/ticketRoutes' +import { ticketNumber } from '../lib/ticketView' /** - * Projekt-Kontextbox im Ticket: zeigt automatisch das zugewiesene Projekt - * und alle anderen Tickets desselben Kunden (klickbar). + * Kontextbox im Ticket: "Weitere Tickets von " (klickbar). * Rendert nichts, wenn dem Ticket kein Projekt zugewiesen ist. */ export default function ProjectContextPanel({ ticket }) { const { fetchByTicketId } = useWebsiteProjects() + const location = useLocation() const [context, setContext] = useState(null) useEffect(() => { @@ -23,7 +25,6 @@ export default function ProjectContextPanel({ ticket }) { if (!cancelled) setContext(null) return } - const projectName = assignedProjects[0].projectName || '' let otherTickets = [] if (ticket.customerId) { @@ -34,7 +35,7 @@ export default function ProjectContextPanel({ ticket }) { ]) otherTickets = res.documents.filter((t) => t.$id !== ticket.$id) } - if (!cancelled) setContext({ projectName, otherTickets }) + if (!cancelled) setContext({ otherTickets }) } catch { if (!cancelled) setContext(null) } @@ -44,44 +45,27 @@ export default function ProjectContextPanel({ ticket }) { }, [ticket.$id, ticket.customerId, fetchByTicketId]) if (!context) return null - const { projectName, otherTickets } = context + const { otherTickets } = context return ( -
-
-
- Projekt: {projectName || '-'} -
-
- + {!ticket.customerId ? ( -

- Kein Kunde zugeordnet — andere Tickets koennen nicht automatisch verknuepft werden. -

+

Kein Kunde zugeordnet — andere Tickets koennen nicht automatisch verknuepft werden.

) : !otherTickets.length ? ( -

- Keine weiteren Tickets zu diesem Kunden/Projekt. -

+

Keine weiteren Tickets zu diesem Kunden/Projekt.

) : ( -
+
{otherTickets.map((t) => ( - - {t.woid} - - {t.topic || t.title || '-'} + + + {ticketNumber(t)} + {t.topic || t.title || '-'} ))}
)} -
+
) } diff --git a/src/components/UrgentToggle.jsx b/src/components/UrgentToggle.jsx index bee0039..860ea7f 100644 --- a/src/components/UrgentToggle.jsx +++ b/src/components/UrgentToggle.jsx @@ -1,7 +1,7 @@ import { isUrgent, priorityFor } from '../lib/ticketStatus' -// Ein Klick markiert ein Ticket als dringend oder wieder als normal -export default function UrgentToggle({ value, onChange }) { +// Ein Klick markiert ein Ticket als dringend oder wieder als normal; onChange bekommt schon die Prioritaetszahl +export default function UrgentToggle({ value, onChange, disabled }) { const urgent = isUrgent(value) return ( ) diff --git a/src/components/WorksheetList.jsx b/src/components/WorksheetList.jsx index 2b579bf..b9daa22 100644 --- a/src/components/WorksheetList.jsx +++ b/src/components/WorksheetList.jsx @@ -1,342 +1,115 @@ -import { useState } from 'react' -import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp, FaPen, FaCheck, FaHandsHelping } from 'react-icons/fa' -import { serviceTypeLabel, needsTimeEntry, worksheetTone } from '../lib/ticketForm' -import { statusLabel } from '../lib/ticketStatus' -import { parseSupporters, supportMinutes, firstName } from '../lib/support' +import { EmptyState } from './ui' +import { needsTimeEntry, isGitWorksheet } from '../lib/ticketForm' +import { parseSupporters } from '../lib/support' +import { + showAboToggle, + statusAfterLabel, + supportLines, + timeLabel, + worksheetBadge, + worksheetKindLabel, + worksheetWhen, +} from '../lib/worksheetView' -// Randfarbe je Zustand (worksheetTone): orange = Git-Zeit fehlt, gruen = Git-Zeit eingetragen. Der Kopf ist fuer alle Zustaende -// grau (Schrift --text auf --bg-subtle); P2 baut die Liste um. -const TONES = { - 'git-open': { border: 'var(--orange-fg)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' }, - 'git-done': { border: 'var(--green-fg)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' }, - comment: { border: 'var(--blue-fg)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' }, - work: { border: 'var(--text-2)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' }, -} - -// Hinweise im Kopf: graue Badge-Toene -const headerBadgeStyle = { background: 'var(--grey-bg)', color: 'var(--grey-fg)', marginLeft: '8px' } - -const headerButtonStyle = { - background: 'var(--bg)', - color: 'var(--text)', - border: '1px solid var(--line-strong)', - borderRadius: '6px', - padding: '2px 10px', - fontSize: '0.8rem', - marginRight: '8px', -} - -export default function WorksheetList({ worksheets, totalTime, loading, onTogglePlan, aboHoursAllowed = true, canEditTimes, onEditTimes, canAddSupport, onOwnSupport, currentUserId }) { - const [expandedWorksheets, setExpandedWorksheets] = useState({}) - if (loading) { - return ( -
-
- Lädt … -
-
- ) - } +// Arbeitsblaetter, neueste zuerst wie geliefert: links Datum und Beginn, in der Mitte Person, Art, Text und Aktionen, rechts die Zeit +export default function WorksheetList({ + worksheets, + loading, + onTogglePlan, + aboHoursAllowed = true, + canEditTimes, + onEditTimes, + canAddSupport, + onOwnSupport, + currentUserId, +}) { + if (loading) return
Wird geladen …
if (!worksheets || worksheets.length === 0) { - return ( -
- - Noch keine Arbeitsblätter. Leg das erste mit „Arbeitsblatt hinzufügen“ an. -
- ) - } - - const formatTime = (minutes) => { - if (!minutes || minutes === 0) return '-' - const hours = Math.floor(minutes / 60) - const mins = minutes % 60 - return hours > 0 ? `${hours}h ${mins}min` : `${mins}min` - } - - const formatDateTime = (date, time) => { - if (!time) return date - const hours = time.substring(0, 2) - const mins = time.substring(2, 4) - return `${date} ${hours}:${mins}` - } - - const toggleWorksheet = (wsid) => { - setExpandedWorksheets(prev => ({ - ...prev, - [wsid]: !prev[wsid] - })) + return } return ( -
- {/* Worksheet-Einträge */} -
- {worksheets.map((ws, index) => { - const isExpanded = expandedWorksheets[ws.wsid] || false - const isGit = ws.serviceType === 'GIT' - const tone = worksheetTone(ws) - const colors = TONES[tone] - const supporters = ws.isComment ? [] : parseSupporters(ws.supporters) - - return ( -
-
{ - e.currentTarget.style.transform = 'translateY(-2px)' - e.currentTarget.style.boxShadow = 'var(--shadow-btn)' - }} onMouseLeave={(e) => { - e.currentTarget.style.transform = 'translateY(0)' - e.currentTarget.style.boxShadow = 'none' - }}> - {/* Header - Immer sichtbar, klickbar */} -
toggleWorksheet(ws.wsid)} - style={{ - background: colors.base, - color: 'var(--text)', - border: 'none', - cursor: 'pointer', - userSelect: 'none', - transition: 'background 0.2s ease' - }} - onMouseEnter={(e) => { - e.currentTarget.style.background = colors.hover - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = colors.base - }} - > -
- {isExpanded ? ( - - ) : ( - - )} -
- WSID {ws.wsid} - {ws.isComment && !isGit && ( - - Kommentar - - )} - {tone === 'git-open' && ( - - Zeit offen - - )} - {tone === 'git-done' && ( - - Zeit eingetragen - - )} - {supporters.length > 0 && ( - - +{supporters.length} Unterstützung - - )} - {ws.countsToPlan && !ws.isComment && ( - - Abo-Stunden - - )} -
- {/* Collapsed: Mitarbeiter & Zeit im Header */} - {!isExpanded && ( -
-
- - {ws.employeeName} -
- {!ws.isComment && ( -
- - {formatTime(ws.totalTime)} -
- )} - {serviceTypeLabel(ws.serviceType)} -
- )} -
-
- {onEditTimes && canEditTimes?.(ws) && ( - - )} - {onOwnSupport && canAddSupport?.(ws) && ( - - )} - {onTogglePlan && !ws.isComment && Number(ws.totalTime) > 0 && (aboHoursAllowed || ws.countsToPlan) && ( - - )} - - {formatDateTime(ws.startDate, ws.startTime) || formatDateTime(ws.endDate, ws.endTime)} - -
-
- - {/* Body - Nur wenn expanded */} - {isExpanded && ( -
+ {worksheets.map((ws) => { + const when = worksheetWhen(ws) + const kind = worksheetKindLabel(ws) + const badge = worksheetBadge(ws) + const lines = supportLines(ws) + const status = statusAfterLabel(ws) + const time = timeLabel(ws) + const canEdit = Boolean(onEditTimes && canEditTimes?.(ws)) + const canSupport = Boolean(onOwnSupport && canAddSupport?.(ws)) + const ownSupport = parseSupporters(ws.supporters).some((s) => s.employeeId === currentUserId) + const showAbo = Boolean(onTogglePlan && showAboToggle(ws, aboHoursAllowed)) + const isNote = ws.isComment && !isGitWorksheet(ws) + + return ( +
+
+
{when.date || '–'}
+ {when.time &&
{when.time}
} +
+ +
+
+ {ws.employeeName} + {ws.employeeShort && {ws.employeeShort}} + {kind && {kind}} + WSID {ws.wsid} + {badge && {badge.text}} + {showAbo ? ( +
+ {ws.countsToPlan ? 'Abo' : '+ Abo'} + + ) : ( + ws.countsToPlan && !ws.isComment && Abo )}
+ + {ws.details &&
{ws.details}
} + {lines.map((line, i) => ( +
{line}
+ ))} + {status &&
{status}
} + + {(canEdit || canSupport) && ( +
+ {canEdit && ( + + )} + {canSupport && ( + + )} +
+ )}
- ) - })} -
- - + +
+ {time &&
{time}
} +
+
+ ) + })}
) } - diff --git a/src/components/WorksheetSummary.jsx b/src/components/WorksheetSummary.jsx index 227211f..c7cb637 100644 --- a/src/components/WorksheetSummary.jsx +++ b/src/components/WorksheetSummary.jsx @@ -1,25 +1,17 @@ -import { needsTimeEntry } from '../lib/ticketForm' -import { worksheetMinutes } from '../lib/support' +import { Stat } from './ui' +import { worksheetSums } from '../lib/worksheetView' +import { stepLabel } from '../lib/timeSteps' -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 (inkl. Unterstuetzung), Anzahl, offene Git-Zeiten +// Kennzahlen ueber der Arbeitsblatt-Liste: Arbeitszeit (inkl. Unterstuetzung), davon Abo-Stunden, 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 + worksheetMinutes(ws), 0) - const open = worksheets.filter(needsTimeEntry).length + const { totalMinutes, aboMinutes, openCount } = worksheetSums(worksheets) return ( -
- Arbeitszeit gesamt {formatTime(minutes)} - {worksheets.length} {worksheets.length === 1 ? 'Arbeitsblatt' : 'Arbeitsblätter'} - {open > 0 && {open} {open === 1 ? 'Zeit offen' : 'Zeiten offen'}} +
+ + + 0 ? 'warn' : ''} />
) } diff --git a/src/lib/elapsed.js b/src/lib/elapsed.js new file mode 100644 index 0000000..ec2d318 --- /dev/null +++ b/src/lib/elapsed.js @@ -0,0 +1,9 @@ +import { formatDistanceToNow } from 'date-fns' +import { de } from 'date-fns/locale' +import { createdDate } from './ticketView' + +// "vor 3 Tagen": dünner Wrapper über date-fns, ohne eigenen Test. Ohne gültiges Erstelldatum steht ein Strich. +export function elapsedLabel(ticket) { + const created = createdDate(ticket) + return created ? formatDistanceToNow(created, { locale: de, addSuffix: true }) : '–' +} diff --git a/src/styles/global.css b/src/styles/global.css index dce996d..efef141 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1014,6 +1014,51 @@ a.list-row:hover .cell-title { color: var(--accent-ink); } .fin-bank-facts > div { grid-template-columns: minmax(0, 1fr); gap: 2px; } } +/* ===== Tickets (P2) ===== */ +/* Arbeitsblatt-Zeilen, Zeilenlinks, Nachladen und Unterstuetzungs-Zeile. Steht direkt vor KOMPAT: gewinnt ueber die Bausteinregeln */ +/* oberhalb durch die Reihenfolge, nie durch eine Erzwingung. */ + +/* ---------- Arbeitsblaetter ---------- */ +.ws-list { margin-bottom: 8px; } +.ws { padding: 16px 0; border-bottom: 1px solid var(--line); display: grid; grid-template-columns: 148px minmax(0, 1fr) auto; gap: 4px 24px; } +.ws:first-of-type { border-top: 1px solid var(--line); } +.ws-when { color: var(--text-2); font-size: 13px; line-height: 20px; } +.ws-when .d { color: var(--text); font-weight: 500; font-size: 14px; } +.ws-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.ws-head .who { font-weight: 600; overflow-wrap: anywhere; } +.ws-text { color: var(--text); margin-top: 4px; max-width: 640px; white-space: pre-wrap; overflow-wrap: anywhere; } +.ws-support { margin-top: 8px; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; } +.ws-support b { color: var(--text); font-weight: 500; } +.ws-acts { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 8px; font-size: 13px; } +.ws-time { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +.ws-time .t { font-weight: 600; } +.ws-time .r { color: var(--text-2); font-size: 13px; } +.ws.comment .ws-text { color: var(--text-2); } +button.link, .abo-toggle { background: none; border: 0; padding: 0; font: inherit; cursor: pointer; } +button.link { font-weight: 500; } /* font: inherit setzt sonst das Gewicht von .link zurueck */ +.abo-toggle { font-size: 12px; font-weight: 500; color: var(--text-2); } +.abo-toggle:hover { color: var(--text); } +.abo-toggle.on { color: var(--blue-fg); } + +/* ---------- Zeilenlinks, Nachladen, Suche ---------- */ +.row-link { display: inline-flex; align-items: baseline; gap: 8px; min-width: 0; color: inherit; overflow-wrap: anywhere; } +.table td .cell-sub { overflow-wrap: anywhere; } +.list-more { display: flex; justify-content: center; padding: 8px 0 24px; } +@media (min-width: 861px) { + .toolbar .search { width: 360px; } +} + +/* ---------- Unterstuetzung im Dialog ---------- */ +.supporter-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px 12px; align-items: center; margin-top: 8px; } +.supporter-row .sup-meta { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; font-size: 13px; color: var(--text-2); } + +@media (max-width: 860px) { + .ws { grid-template-columns: minmax(0, 1fr) auto; } + .ws-when { grid-column: 1 / -1; display: flex; gap: 8px; } + .supporter-row { grid-template-columns: minmax(0, 1fr); } +} +/* ===== ENDE Tickets (P2) ===== */ + /* ===== KOMPAT (bis P10) ===== */ /* Uebergangsschicht: alte Klassen im neuen Look, damit Seiten und Dialoge, die noch nicht umgebaut sind, weiss, kastenlos */ /* und lesbar bleiben. Steht am Dateiende und gewinnt dort gegen die Altregeln und gegen die anderen Stylesheets (global.css */