Tickets: Arbeitsblatt-Zeilen (.ws), Kennzahlen, Dringend-Schalter und Weitere Tickets im neuen Look
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab42918bbc
commit
6eb5ab3679
6 files changed
+184
-379
No files matched your search
@@ -1,17 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import { IconFolders } from '@tabler/icons-react'
|
import { Card } from './ui'
|
||||||
import { StatusPill } from './ui/StatusPill'
|
import { StatusPill } from './ui/StatusPill'
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
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
|
* Kontextbox im Ticket: "Weitere Tickets von <Kunde>" (klickbar).
|
||||||
* und alle anderen Tickets desselben Kunden (klickbar).
|
|
||||||
* Rendert nichts, wenn dem Ticket kein Projekt zugewiesen ist.
|
* Rendert nichts, wenn dem Ticket kein Projekt zugewiesen ist.
|
||||||
*/
|
*/
|
||||||
export default function ProjectContextPanel({ ticket }) {
|
export default function ProjectContextPanel({ ticket }) {
|
||||||
const { fetchByTicketId } = useWebsiteProjects()
|
const { fetchByTicketId } = useWebsiteProjects()
|
||||||
|
const location = useLocation()
|
||||||
const [context, setContext] = useState(null)
|
const [context, setContext] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -23,7 +25,6 @@ export default function ProjectContextPanel({ ticket }) {
|
|||||||
if (!cancelled) setContext(null)
|
if (!cancelled) setContext(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const projectName = assignedProjects[0].projectName || ''
|
|
||||||
|
|
||||||
let otherTickets = []
|
let otherTickets = []
|
||||||
if (ticket.customerId) {
|
if (ticket.customerId) {
|
||||||
@@ -34,7 +35,7 @@ export default function ProjectContextPanel({ ticket }) {
|
|||||||
])
|
])
|
||||||
otherTickets = res.documents.filter((t) => t.$id !== ticket.$id)
|
otherTickets = res.documents.filter((t) => t.$id !== ticket.$id)
|
||||||
}
|
}
|
||||||
if (!cancelled) setContext({ projectName, otherTickets })
|
if (!cancelled) setContext({ otherTickets })
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setContext(null)
|
if (!cancelled) setContext(null)
|
||||||
}
|
}
|
||||||
@@ -44,44 +45,27 @@ export default function ProjectContextPanel({ ticket }) {
|
|||||||
}, [ticket.$id, ticket.customerId, fetchByTicketId])
|
}, [ticket.$id, ticket.customerId, fetchByTicketId])
|
||||||
|
|
||||||
if (!context) return null
|
if (!context) return null
|
||||||
const { projectName, otherTickets } = context
|
const { otherTickets } = context
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ui-card pad" style={{ marginTop: 16 }}>
|
<Card title={ticket.customerName ? `Weitere Tickets von ${ticket.customerName}` : 'Weitere Tickets'}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 10 }}>
|
|
||||||
<h5 style={{ fontWeight: 700, margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<IconFolders size={18} /> Projekt: {projectName || '-'}
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!ticket.customerId ? (
|
{!ticket.customerId ? (
|
||||||
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
|
<p className="muted">Kein Kunde zugeordnet — andere Tickets koennen nicht automatisch verknuepft werden.</p>
|
||||||
Kein Kunde zugeordnet — andere Tickets koennen nicht automatisch verknuepft werden.
|
|
||||||
</p>
|
|
||||||
) : !otherTickets.length ? (
|
) : !otherTickets.length ? (
|
||||||
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
|
<p className="muted">Keine weiteren Tickets zu diesem Kunden/Projekt.</p>
|
||||||
Keine weiteren Tickets zu diesem Kunden/Projekt.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div>
|
||||||
{otherTickets.map((t) => (
|
{otherTickets.map((t) => (
|
||||||
<Link
|
<Link key={t.$id} className="list-row" to={ticketPath(ticketRef(t))} state={location.state}>
|
||||||
key={t.$id}
|
<span className="cell-title list-row-text">
|
||||||
to={`/tickets?woid=${t.woid}`}
|
<span className="id">{ticketNumber(t)}</span>
|
||||||
style={{
|
<span>{t.topic || t.title || '-'}</span>
|
||||||
display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, color: 'var(--text)', textDecoration: 'none',
|
|
||||||
padding: '6px 8px', borderRadius: 8, border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="muted" style={{ minWidth: 48, fontWeight: 700 }}>{t.woid}</span>
|
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{t.topic || t.title || '-'}
|
|
||||||
</span>
|
</span>
|
||||||
<StatusPill status={t.status} />
|
<StatusPill status={t.status} />
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { isUrgent, priorityFor } from '../lib/ticketStatus'
|
import { isUrgent, priorityFor } from '../lib/ticketStatus'
|
||||||
|
|
||||||
// Ein Klick markiert ein Ticket als dringend oder wieder als normal
|
// Ein Klick markiert ein Ticket als dringend oder wieder als normal; onChange bekommt schon die Prioritaetszahl
|
||||||
export default function UrgentToggle({ value, onChange }) {
|
export default function UrgentToggle({ value, onChange, disabled }) {
|
||||||
const urgent = isUrgent(value)
|
const urgent = isUrgent(value)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -10,7 +10,9 @@ export default function UrgentToggle({ value, onChange }) {
|
|||||||
aria-pressed={urgent}
|
aria-pressed={urgent}
|
||||||
title={urgent ? 'Nicht mehr dringend' : 'Als dringend markieren'}
|
title={urgent ? 'Nicht mehr dringend' : 'Als dringend markieren'}
|
||||||
onClick={() => onChange(priorityFor(!urgent))}
|
onClick={() => onChange(priorityFor(!urgent))}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
<span className="sw" aria-hidden="true" />
|
||||||
Dringend
|
Dringend
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
+100
-327
@@ -1,342 +1,115 @@
|
|||||||
import { useState } from 'react'
|
import { EmptyState } from './ui'
|
||||||
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp, FaPen, FaCheck, FaHandsHelping } from 'react-icons/fa'
|
import { needsTimeEntry, isGitWorksheet } from '../lib/ticketForm'
|
||||||
import { serviceTypeLabel, needsTimeEntry, worksheetTone } from '../lib/ticketForm'
|
import { parseSupporters } from '../lib/support'
|
||||||
import { statusLabel } from '../lib/ticketStatus'
|
import {
|
||||||
import { parseSupporters, supportMinutes, firstName } from '../lib/support'
|
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
|
// Arbeitsblaetter, neueste zuerst wie geliefert: links Datum und Beginn, in der Mitte Person, Art, Text und Aktionen, rechts die Zeit
|
||||||
// grau (Schrift --text auf --bg-subtle); P2 baut die Liste um.
|
export default function WorksheetList({
|
||||||
const TONES = {
|
worksheets,
|
||||||
'git-open': { border: 'var(--orange-fg)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' },
|
loading,
|
||||||
'git-done': { border: 'var(--green-fg)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' },
|
onTogglePlan,
|
||||||
comment: { border: 'var(--blue-fg)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' },
|
aboHoursAllowed = true,
|
||||||
work: { border: 'var(--text-2)', base: 'var(--bg-subtle)', hover: 'var(--bg-hover)' },
|
canEditTimes,
|
||||||
}
|
onEditTimes,
|
||||||
|
canAddSupport,
|
||||||
// Hinweise im Kopf: graue Badge-Toene
|
onOwnSupport,
|
||||||
const headerBadgeStyle = { background: 'var(--grey-bg)', color: 'var(--grey-fg)', marginLeft: '8px' }
|
currentUserId,
|
||||||
|
}) {
|
||||||
const headerButtonStyle = {
|
if (loading) return <div className="loading">Wird geladen …</div>
|
||||||
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 (
|
|
||||||
<div className="text-center p-4">
|
|
||||||
<div className="spinner-border" role="status">
|
|
||||||
<span className="visually-hidden">Lädt …</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!worksheets || worksheets.length === 0) {
|
if (!worksheets || worksheets.length === 0) {
|
||||||
return (
|
return <EmptyState title="Noch keine Arbeitsblätter." hint="Leg das erste mit „Arbeitsblatt hinzufügen“ an." />
|
||||||
<div className="alert alert-info" role="alert">
|
|
||||||
<FaComment className="me-2" />
|
|
||||||
Noch keine Arbeitsblätter. Leg das erste mit „Arbeitsblatt hinzufügen“ an.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
||||||
<div className="worksheet-list">
|
<div className="ws-list">
|
||||||
{/* Worksheet-Einträge */}
|
{worksheets.map((ws) => {
|
||||||
<div className="timeline">
|
const when = worksheetWhen(ws)
|
||||||
{worksheets.map((ws, index) => {
|
const kind = worksheetKindLabel(ws)
|
||||||
const isExpanded = expandedWorksheets[ws.wsid] || false
|
const badge = worksheetBadge(ws)
|
||||||
const isGit = ws.serviceType === 'GIT'
|
const lines = supportLines(ws)
|
||||||
const tone = worksheetTone(ws)
|
const status = statusAfterLabel(ws)
|
||||||
const colors = TONES[tone]
|
const time = timeLabel(ws)
|
||||||
const supporters = ws.isComment ? [] : parseSupporters(ws.supporters)
|
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 (
|
return (
|
||||||
<div key={ws.$id} className="timeline-item mb-4" style={{
|
<div key={ws.$id} className={isNote ? 'ws comment' : 'ws'}>
|
||||||
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
|
<div className="ws-when">
|
||||||
}}>
|
<div className="d">{when.date || '–'}</div>
|
||||||
<div className="card border-0 shadow-sm overflow-hidden" style={{
|
{when.time && <div className="num">{when.time}</div>}
|
||||||
borderLeft: `4px solid ${colors.border}`,
|
</div>
|
||||||
borderRadius: '8px',
|
|
||||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
|
||||||
}} onMouseEnter={(e) => {
|
|
||||||
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 */}
|
|
||||||
<div
|
|
||||||
className="card-header d-flex justify-content-between align-items-center py-3"
|
|
||||||
onClick={() => 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
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="d-flex align-items-center gap-3">
|
|
||||||
{isExpanded ? (
|
|
||||||
<FaChevronUp style={{ fontSize: '0.9rem', opacity: 0.8 }} />
|
|
||||||
) : (
|
|
||||||
<FaChevronDown style={{ fontSize: '0.9rem', opacity: 0.8 }} />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<strong className="fs-6">WSID {ws.wsid}</strong>
|
|
||||||
{ws.isComment && !isGit && (
|
|
||||||
<span className="badge ms-2" style={headerBadgeStyle}>
|
|
||||||
<FaComment className="me-1" /> Kommentar
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{tone === 'git-open' && (
|
|
||||||
<span className="badge ms-2" style={headerBadgeStyle}>
|
|
||||||
<FaClock className="me-1" /> Zeit offen
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{tone === 'git-done' && (
|
|
||||||
<span className="badge ms-2" style={headerBadgeStyle}>
|
|
||||||
<FaCheck className="me-1" /> Zeit eingetragen
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{supporters.length > 0 && (
|
|
||||||
<span className="badge ms-2" title="Weitere Personen haben unterstützt" style={headerBadgeStyle}>
|
|
||||||
+{supporters.length} Unterstützung
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{ws.countsToPlan && !ws.isComment && (
|
|
||||||
<span className="badge ms-2" title="Wird vom Stundenguthaben des Wartungs-Abos abgezogen" style={{ ...headerBadgeStyle, background: 'var(--violet-bg)', color: 'var(--violet-fg)' }}>
|
|
||||||
Abo-Stunden
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Collapsed: Mitarbeiter & Zeit im Header */}
|
|
||||||
{!isExpanded && (
|
|
||||||
<div className="d-flex align-items-center gap-3 ms-3">
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<FaUser style={{ fontSize: '0.9rem', marginRight: '0.5rem' }} />
|
|
||||||
<span style={{ fontSize: '0.9rem' }}>{ws.employeeName}</span>
|
|
||||||
</div>
|
|
||||||
{!ws.isComment && (
|
|
||||||
<div className="d-flex align-items-center">
|
|
||||||
<FaClock style={{ fontSize: '0.9rem', marginRight: '0.5rem' }} />
|
|
||||||
<span style={{ fontSize: '0.9rem' }}>{formatTime(ws.totalTime)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="badge" style={{
|
|
||||||
background: 'var(--grey-bg)',
|
|
||||||
color: 'var(--grey-fg)',
|
|
||||||
fontSize: '0.8rem'
|
|
||||||
}}>{serviceTypeLabel(ws.serviceType)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="d-flex align-items-center gap-2">
|
|
||||||
{onEditTimes && canEditTimes?.(ws) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-sm"
|
|
||||||
title="Beginn, Ende und Arbeitszeit ändern"
|
|
||||||
style={headerButtonStyle}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onEditTimes(ws)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{needsTimeEntry(ws)
|
|
||||||
? <><FaClock className="me-1" /> Zeit nachtragen</>
|
|
||||||
: <><FaPen className="me-1" /> Zeiten bearbeiten</>}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onOwnSupport && canAddSupport?.(ws) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-sm"
|
|
||||||
title="Eintragen, dass du bei dieser Arbeit unterstützt hast"
|
|
||||||
style={headerButtonStyle}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onOwnSupport(ws)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaHandsHelping className="me-1" />{' '}
|
|
||||||
{parseSupporters(ws.supporters).some((s) => s.employeeId === currentUserId)
|
|
||||||
? 'Meine Unterstützung bearbeiten'
|
|
||||||
: 'Ich habe unterstützt'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onTogglePlan && !ws.isComment && Number(ws.totalTime) > 0 && (aboHoursAllowed || ws.countsToPlan) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-sm"
|
|
||||||
title={ws.countsToPlan ? 'Nicht mehr vom Abo-Stundenkonto abziehen' : 'Vom Abo-Stundenkonto des Kunden abziehen'}
|
|
||||||
style={{
|
|
||||||
background: ws.countsToPlan ? 'var(--violet-bg)' : 'var(--bg)',
|
|
||||||
color: ws.countsToPlan ? 'var(--violet-fg)' : 'var(--text)',
|
|
||||||
border: '1px solid var(--line-strong)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
padding: '2px 10px',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
marginRight: '8px',
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onTogglePlan(ws)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ws.countsToPlan ? '✓ Abo' : '+ Abo'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<small style={{ opacity: 0.9 }}>
|
|
||||||
{formatDateTime(ws.startDate, ws.startTime) || formatDateTime(ws.endDate, ws.endTime)}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body - Nur wenn expanded */}
|
<div>
|
||||||
{isExpanded && (
|
<div className="ws-head">
|
||||||
<div
|
<span className="who">{ws.employeeName}</span>
|
||||||
className="card-body p-4"
|
{ws.employeeShort && <span className="faint xs">{ws.employeeShort}</span>}
|
||||||
style={{
|
{kind && <span className="muted">{kind}</span>}
|
||||||
animation: 'slideDown 0.3s ease-out'
|
<span className="faint xs num">WSID {ws.wsid}</span>
|
||||||
}}
|
{badge && <span className={`badge ${badge.tone}`}>{badge.text}</span>}
|
||||||
|
{showAbo ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={ws.countsToPlan ? 'abo-toggle on' : 'abo-toggle'}
|
||||||
|
title={ws.countsToPlan ? 'Nicht mehr vom Abo-Stundenkonto abziehen' : 'Vom Abo-Stundenkonto des Kunden abziehen'}
|
||||||
|
onClick={() => onTogglePlan(ws)}
|
||||||
>
|
>
|
||||||
{/* Mitarbeiter & Zeit */}
|
{ws.countsToPlan ? 'Abo' : '+ Abo'}
|
||||||
<div className="row mb-3">
|
</button>
|
||||||
<div className="col-md-6">
|
) : (
|
||||||
<div className="d-flex align-items-center">
|
ws.countsToPlan && !ws.isComment && <span className="muted xs">Abo</span>
|
||||||
<FaUser className="me-2" style={{ color: 'var(--green-fg)' }} />
|
|
||||||
<strong>{ws.employeeName}</strong>
|
|
||||||
{ws.employeeShort && (
|
|
||||||
<span className="badge ms-2" style={{
|
|
||||||
background: 'var(--green-bg)',
|
|
||||||
color: 'var(--green-fg)'
|
|
||||||
}}>{ws.employeeShort}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-6 text-md-end">
|
|
||||||
{!ws.isComment && (
|
|
||||||
<div className="d-flex align-items-center justify-content-md-end">
|
|
||||||
<FaClock className="me-2" style={{ color: 'var(--green-fg)' }} />
|
|
||||||
<strong className="fs-5" style={{ color: 'var(--green-fg)' }}>{formatTime(ws.totalTime)}</strong>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Unterstuetzung: je Person die Haelfte der Arbeitszeit, aufgerundet auf 15 Minuten */}
|
|
||||||
{supporters.length > 0 && (
|
|
||||||
<div className="mb-3 worksheet-supporters">
|
|
||||||
{supporters.map((sup) => (
|
|
||||||
<div key={sup.employeeId} style={{ fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
|
||||||
↳ Unterstützung {firstName(sup.name)} · {sup.task} · {formatTime(supportMinutes(ws.totalTime))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Service Type */}
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="badge px-3 py-2" style={{
|
|
||||||
background: 'var(--grey-bg)',
|
|
||||||
color: 'var(--grey-fg)',
|
|
||||||
fontSize: '0.85rem'
|
|
||||||
}}>{serviceTypeLabel(ws.serviceType)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status-Änderung */}
|
|
||||||
{ws.oldStatus !== ws.newStatus && (
|
|
||||||
<div className="mb-3 p-3 rounded-3" style={{
|
|
||||||
background: 'var(--bg-subtle)'
|
|
||||||
}}>
|
|
||||||
<FaExchangeAlt className="me-2" style={{ color: 'var(--green-fg)' }} />
|
|
||||||
<span className="text-muted">Status:</span>{' '}
|
|
||||||
<span className="badge" style={{ background: 'var(--grey-bg)', color: 'var(--grey-fg)' }}>{statusLabel(ws.oldStatus)}</span>
|
|
||||||
<span className="mx-2" style={{ color: 'var(--green-fg)' }}>→</span>
|
|
||||||
<span className="badge" style={{ background: 'var(--green-bg)', color: 'var(--green-fg)' }}>{statusLabel(ws.newStatus)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Details */}
|
|
||||||
<div className="mt-3 p-3 rounded-3" style={{
|
|
||||||
background: 'var(--bg-subtle)',
|
|
||||||
border: '1px solid var(--line)'
|
|
||||||
}}>
|
|
||||||
<small className="text-dark" style={{ whiteSpace: 'pre-wrap', lineHeight: '1.6' }}>
|
|
||||||
{ws.details}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>{`
|
{ws.details && <div className="ws-text">{ws.details}</div>}
|
||||||
@keyframes fadeIn {
|
{lines.map((line, i) => (
|
||||||
from {
|
<div key={i} className="ws-support">{line}</div>
|
||||||
opacity: 0;
|
))}
|
||||||
transform: translateY(20px);
|
{status && <div className="ws-support">{status}</div>}
|
||||||
}
|
|
||||||
to {
|
{(canEdit || canSupport) && (
|
||||||
opacity: 1;
|
<div className="ws-acts">
|
||||||
transform: translateY(0);
|
{canEdit && (
|
||||||
}
|
<button
|
||||||
}
|
type="button"
|
||||||
@keyframes slideDown {
|
className="link"
|
||||||
from {
|
title="Beginn, Ende und Arbeitszeit ändern"
|
||||||
opacity: 0;
|
onClick={() => onEditTimes(ws)}
|
||||||
max-height: 0;
|
>
|
||||||
transform: translateY(-10px);
|
{needsTimeEntry(ws) ? 'Zeit nachtragen' : 'Zeiten bearbeiten'}
|
||||||
}
|
</button>
|
||||||
to {
|
)}
|
||||||
opacity: 1;
|
{canSupport && (
|
||||||
max-height: 1000px;
|
<button
|
||||||
transform: translateY(0);
|
type="button"
|
||||||
}
|
className="link"
|
||||||
}
|
title="Eintragen, dass du bei dieser Arbeit unterstützt hast"
|
||||||
`}</style>
|
onClick={() => onOwnSupport(ws)}
|
||||||
|
>
|
||||||
|
{ownSupport ? 'Meine Unterstützung bearbeiten' : 'Ich habe unterstützt'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ws-time">
|
||||||
|
{time && <div className={time === '–' ? 't faint' : 't'}>{time}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,25 +1,17 @@
|
|||||||
import { needsTimeEntry } from '../lib/ticketForm'
|
import { Stat } from './ui'
|
||||||
import { worksheetMinutes } from '../lib/support'
|
import { worksheetSums } from '../lib/worksheetView'
|
||||||
|
import { stepLabel } from '../lib/timeSteps'
|
||||||
|
|
||||||
const formatTime = (minutes) => {
|
// Kennzahlen ueber der Arbeitsblatt-Liste: Arbeitszeit (inkl. Unterstuetzung), davon Abo-Stunden, offene Git-Zeiten
|
||||||
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
|
|
||||||
export default function WorksheetSummary({ worksheets }) {
|
export default function WorksheetSummary({ worksheets }) {
|
||||||
if (!worksheets || worksheets.length === 0) return null
|
if (!worksheets || worksheets.length === 0) return null
|
||||||
const minutes = worksheets
|
const { totalMinutes, aboMinutes, openCount } = worksheetSums(worksheets)
|
||||||
.filter((ws) => !ws.isComment)
|
|
||||||
.reduce((sum, ws) => sum + worksheetMinutes(ws), 0)
|
|
||||||
const open = worksheets.filter(needsTimeEntry).length
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="worksheet-summary">
|
<div className="stats">
|
||||||
<span>Arbeitszeit gesamt <strong>{formatTime(minutes)}</strong></span>
|
<Stat label="Arbeitszeit gesamt" value={stepLabel(totalMinutes)} />
|
||||||
<span>{worksheets.length} {worksheets.length === 1 ? 'Arbeitsblatt' : 'Arbeitsblätter'}</span>
|
<Stat label="Davon Abo-Stunden" value={stepLabel(aboMinutes)} />
|
||||||
{open > 0 && <span className="open">{open} {open === 1 ? 'Zeit offen' : 'Zeiten offen'}</span>}
|
<Stat label="Zeiten offen" value={openCount} tone={openCount > 0 ? 'warn' : ''} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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 }) : '–'
|
||||||
|
}
|
||||||
@@ -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; }
|
.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) ===== */
|
/* ===== KOMPAT (bis P10) ===== */
|
||||||
/* Uebergangsschicht: alte Klassen im neuen Look, damit Seiten und Dialoge, die noch nicht umgebaut sind, weiss, kastenlos */
|
/* 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 */
|
/* und lesbar bleiben. Steht am Dateiende und gewinnt dort gegen die Altregeln und gegen die anderen Stylesheets (global.css */
|
||||||
|
|||||||
Reference in new issue
Block a user