Merge origin/test (WOMS-Überarbeitung Planboard/Tickets) in feature/oeffnungszeiten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 08:56:02 +00:00
commit 35e2761e68
9002 files changed
+12366 -1176888

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
/**
* Soll diese Taste den Dialog schließen? Nur Esc, nur wenn gerade nichts gespeichert wird (`busy`) und nur, wenn
* niemand die Taste schon behandelt hat (`defaultPrevented`, z. B. schließt eine offene Vorschlagsliste zuerst sich selbst).
*/
export function shouldCloseOnKey({ key, defaultPrevented }, busy) {
return key === 'Escape' && !defaultPrevented && !busy
}
+9
View File
@@ -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 }) : '–'
}
+3 -3
View File
@@ -1,5 +1,5 @@
// Eintraege der Sidebar als reine Daten (unter Node testbar); Navbar.jsx ordnet die Icons ueber key zu.
// Unterpunkte (children) erscheinen eingerueckt unter ihrem Eintrag, count zeigt eine Zahl (nur > 0).
// Eintraege der Sidebar als reine Daten (unter Node testbar).
// Unterpunkte (children) erscheinen eingerueckt unter ihrem Eintrag, count zeigt eine Zahl (nur > 0), hot hebt die Zahl farbig hervor.
export function buildNavLinks({ isAdmin, pulseLevel, counts = {} }) {
return [
@@ -13,7 +13,7 @@ export function buildNavLinks({ isAdmin, pulseLevel, counts = {} }) {
href: '/customers',
// Bereiche aus dem frueheren Admin-Panel; Zahlen: ungelesene Chats, offene Gutschriften, offene Speicher-Aufgaben
children: [
{ key: 'chat', label: 'Chat', href: '/customers/chat', count: counts.chat ?? 0 },
{ key: 'chat', label: 'Chat', href: '/customers/chat', count: counts.chat ?? 0, hot: true },
{ key: 'gutschriften', label: 'Gutschriften', href: '/customers/gutschriften', count: counts.credits ?? 0 },
{ key: 'angebote', label: 'Abo-Angebote', href: '/customers/angebote' },
{ key: 'speicher', label: 'E-Mail-Speicher', href: '/customers/email-speicher', count: counts.storage ?? 0 },
+45
View File
@@ -0,0 +1,45 @@
/* Planboard-Anzeigelogik: reine Texte und Klassen, die PlanboardPage braucht.
Die Terminlogik selbst (Datum lesen, nach Tag gruppieren) steckt in planboard.js. */
import { format, getISOWeek, addDays } from 'date-fns'
import { de } from 'date-fns/locale'
const NOBODY = 'niemand zugewiesen'
// "Kalenderwoche 41 · 5. Oktober bis 11. Oktober 2026 · 13 Termine"
// weekStart muss ein Montag sein (die Woche läuft bis weekStart + 6 Tage). Das Ende trägt immer das Jahr, der Anfang nur,
// wenn sein Jahr vom Ende abweicht: "28. Dezember 2026 bis 3. Januar 2027".
export function planboardSubtitle({ weekStart, count = 0, loading = false }) {
const weekEnd = addDays(weekStart, 6)
const sameYear = weekStart.getFullYear() === weekEnd.getFullYear()
const from = format(weekStart, sameYear ? 'd. MMMM' : 'd. MMMM yyyy', { locale: de })
const to = format(weekEnd, 'd. MMMM yyyy', { locale: de })
const rest = loading ? 'Wird geladen …' : `${count} ${count === 1 ? 'Termin' : 'Termine'}`
return `Kalenderwoche ${getISOWeek(weekStart)} · ${from} bis ${to} · ${rest}`
}
export function dayLabels(day) {
return {
weekday: format(day, 'EEEE', { locale: de }),
number: format(day, 'dd.MM'),
full: format(day, 'EEEE, d. MMMM yyyy', { locale: de }),
}
}
export function appointmentClass(type) {
return String(type || '').toLowerCase() === 'akquise' ? 'appt akquise' : 'appt'
}
export function appointmentTimeLabel({ time, endTime }) {
if (!time) return 'ohne Uhrzeit'
return endTime ? `${time}–${endTime}` : time
}
// Mit Ticketnummer "#100201 · KNSO", ohne Nummer nur der Mitarbeiter (kein leeres "#")
export function appointmentOwnerLabel({ woid, employeeName, employeeShort }) {
const owner = employeeName ? employeeShort || employeeName : NOBODY
return woid ? `#${woid} · ${owner}` : owner
}
export function appointmentTitle({ woid }) {
return woid ? `Ticket #${woid} öffnen` : 'Ticket öffnen'
}
+14
View File
@@ -0,0 +1,14 @@
/* Nur die juengste Anfrage darf das Ergebnis uebernehmen (spaete, aeltere Antworten werden verworfen) */
export function createRequestGuard() {
let current = 0
return {
next() {
current += 1
return current
},
isCurrent(id) {
return id === current
},
}
}
+13
View File
@@ -0,0 +1,13 @@
/* Klickbare Tabellenzeilen: nur ein einfacher Linksklick auf die Zeile selbst oeffnet sie.
Klicks auf Links, Knoepfe und Formularfelder in der Zeile, Mittelklick und Klicks mit
Modifikatortaste (neuer Tab, Auswahl) bleiben Sache des Browsers bzw. des Elements. */
const INTERACTIVE = 'a,button,select,input,textarea,label'
export function isRowActivation(event) {
if (event.defaultPrevented || event.button !== 0) return false
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
const hit = event.target?.closest?.(INTERACTIVE)
// Ein interaktiver Vorfahre ausserhalb der Zeile zaehlt nicht
return !(hit && event.currentTarget?.contains?.(hit))
}
-12
View File
@@ -1,12 +0,0 @@
// Seitenmenue: auf schmalen Bildschirmen (Handy) startet es eingeklappt, sonst bleiben vom Inhalt bei 390 px nur
// etwa 140 px und jede Seite scrollt waagerecht. Ausklappen geht weiterhin per Knopf.
export const AUTO_COLLAPSE_MAX_WIDTH = 720
/** true, wenn das Menue eingeklappt starten soll (Fensterbreite bis 720 px); ohne window (Test, Server) false. */
export function startsCollapsed(win = typeof window !== 'undefined' ? window : null) {
try {
return Boolean(win?.matchMedia?.(`(max-width: ${AUTO_COLLAPSE_MAX_WIDTH}px)`)?.matches)
} catch {
return false
}
}
+85
View File
@@ -0,0 +1,85 @@
/* Zustand der Ticketliste in der Adresse: status (Reiter-Id), art (Id aus TYPE_FILTERS),
dringend (1), q, limit - in dieser Reihenfolge, Standardwerte werden nicht geschrieben.
Aus dem Zustand entstehen die Filter fuer Liste und Zaehler (beide muessen dieselben nutzen). */
import { STATUS_GROUPS, DEFAULT_STATUS_GROUPS, statusValuesFor, TYPE_FILTERS } from './ticketStatus'
import { ticketPath } from './ticketRoutes'
// Reiter: "Aktiv" (alles Laufende), je eine Statusgruppe, zuletzt "Alle" (groups: null = kein Statusfilter)
export const LIST_TABS = [
{ id: 'aktiv', label: 'Aktiv', groups: DEFAULT_STATUS_GROUPS },
...STATUS_GROUPS.map((group) => ({ id: group.id, label: group.label, groups: [group.id] })),
{ id: 'alle', label: 'Alle', groups: null },
]
export const LIST_DEFAULTS = { tab: 'aktiv', typeId: 'orders', urgentOnly: false, search: '', limit: 10 }
const MIN_LIMIT = 10
const MAX_LIMIT = 500
const LIMIT_STEP = 10
// Hoch (3) und Kritisch (4) gelten als dringend (siehe ticketStatus.js)
const URGENT_PRIORITIES = [3, 4]
export function clampLimit(value) {
const n = Math.floor(Number(value))
if (!Number.isFinite(n)) return MIN_LIMIT
return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, n))
}
// input: URLSearchParams oder String ("?status=done" und "status=done" gehen beide)
export function parseListParams(input) {
const params = new URLSearchParams(input)
const tab = params.get('status')
const typeId = params.get('art')
const limit = params.get('limit')
return {
tab: LIST_TABS.some((t) => t.id === tab) ? tab : LIST_DEFAULTS.tab,
typeId: TYPE_FILTERS.some((f) => f.id === typeId) ? typeId : LIST_DEFAULTS.typeId,
urgentOnly: params.get('dringend') === '1',
search: (params.get('q') || '').trim(),
limit: limit !== null && /^\d+$/.test(limit) ? clampLimit(limit) : LIST_DEFAULTS.limit,
}
}
export function serializeListParams(state) {
const params = new URLSearchParams()
if (state.tab !== LIST_DEFAULTS.tab) params.set('status', state.tab)
if (state.typeId !== LIST_DEFAULTS.typeId) params.set('art', state.typeId)
if (state.urgentOnly) params.set('dringend', '1')
if (state.search) params.set('q', state.search)
const limit = clampLimit(state.limit)
if (limit !== LIST_DEFAULTS.limit) params.set('limit', String(limit))
return params
}
// Jede Filteraenderung beginnt wieder bei der ersten Seite
export function withFilter(state, patch) {
return { ...state, ...patch, limit: LIST_DEFAULTS.limit }
}
// "Mehr laden"
export function nextLimit(state) {
return clampLimit(Number(state.limit) + LIMIT_STEP)
}
// Filter fuer den Zaehler eines Reiters: wie die Liste, aber ohne Suche und limit
export function countFilters(state, tabId) {
const tab = LIST_TABS.find((t) => t.id === tabId) || LIST_TABS[0]
const type = TYPE_FILTERS.find((f) => f.id === state.typeId) || TYPE_FILTERS[0]
return {
status: tab.groups === null ? [] : statusValuesFor(tab.groups),
type: [...(type.types || [])],
excludeType: type.excludeType,
priority: state.urgentOnly ? [...URGENT_PRIORITIES] : [],
}
}
export function listFilters(state) {
return { ...countFilters(state, state.tab), search: state.search || undefined, limit: state.limit }
}
// Altlinks aus den Leads (Liste mit Suchteil woid=100131) fuehren zur Ticketseite und haben Vorrang vor allen Filtern
export function woidRedirectTarget(input) {
const woid = (new URLSearchParams(input).get('woid') || '').trim()
return woid ? ticketPath(woid) : null
}
+28
View File
@@ -0,0 +1,28 @@
/* Adressen der Ticketseite: /tickets/<woid oder $id> mit dem Reiter Details (ohne Zusatz)
oder Arbeitsblaetter. Alle Links auf Tickets entstehen hier. */
// Reiter der Ticketseite; "details" hat keinen eigenen Pfadteil
export const TICKET_TABS = ['details', 'arbeitsblaetter']
// Das Ticket wird ueber seine woid adressiert, sonst ueber die $id (nie ueber die Kurznummer)
export function ticketRef(ticket) {
return String(ticket?.woid ?? '').trim() || String(ticket?.$id ?? '')
}
export function ticketPath(ref, tab) {
const id = String(ref ?? '').trim()
if (!id) return '/tickets'
return '/tickets/' + encodeURIComponent(id) + (tab === 'arbeitsblaetter' ? '/arbeitsblaetter' : '')
}
// Reiter aus der Adresse: fehlt er, gilt "details", ein unbekannter Wert ergibt null
export function resolveTicketTab(tab) {
if (tab === undefined) return 'details'
return TICKET_TABS.includes(tab) ? tab : null
}
// Zurueck zur Liste mit dem gemerkten Suchteil (nur "?..." mit Inhalt, sonst kein Fremdtext in der Adresse)
export function listBackPath(locationState) {
const search = locationState?.listSearch
return typeof search === 'string' && search.startsWith('?') && search.length > 1 ? '/tickets' + search : '/tickets'
}
+22 -15
View File
@@ -32,6 +32,14 @@ export function statusLabel(status) {
return LABELS[status] || status
}
// Auswahl fuer das Status-Feld: die sechs waehlbaren Werte, ein alter Wert des Tickets bleibt zusaetzlich sichtbar
export function statusSelectOptions(current) {
const options = TICKET_STATUSES.map((s) => ({ ...s }))
const value = current || 'Open'
if (!options.some((o) => o.value === value)) options.push({ value, label: statusLabel(value) })
return options
}
// Filter: jede Gruppe fasst die gespeicherten Werte zusammen, auch die alten
export const STATUS_GROUPS = [
{ id: 'open', label: 'Offen', values: ['Open'] },
@@ -49,24 +57,23 @@ export function statusGroup(status) {
return STATUS_GROUPS.find((group) => group.values.includes(status))?.id || null
}
const GROUP_TONE = {
open: '',
working: 'blue',
waiting: 'orange',
review: 'violet',
done: 'green',
cancelled: 'plain',
}
export function statusTone(status) {
return GROUP_TONE[statusGroup(status)] || ''
}
export function statusValuesFor(groupIds) {
return STATUS_GROUPS.filter((group) => groupIds.includes(group.id)).flatMap((group) => group.values)
}
const GROUP_CLASS = {
open: 'status-open',
working: 'status-working',
waiting: 'status-awaiting',
review: 'status-assigned',
done: 'status-closed',
cancelled: 'status-closed',
}
// CSS-Klasse fuer das Status-Abzeichen
export function statusClass(status) {
return GROUP_CLASS[statusGroup(status || 'Open')] || 'status-open'
}
// Statt fuenf Prioritaeten gibt es nur noch "dringend" oder nicht.
// Frueher gespeicherte Stufen: Hoch (3) und Kritisch (4) gelten als dringend.
export const NORMAL_PRIORITY = 2
@@ -93,7 +100,7 @@ export const TYPE_FILTERS = [
{ id: 'Migration', label: 'Migration', types: ['Migration'] },
{ id: 'Project', label: 'Projekt', types: ['Project'] },
{ id: 'Akquise', label: 'Akquise', types: ['Akquise'] },
{ id: 'all', label: 'Alle' },
{ id: 'all', label: 'Aufträge und Akquise' },
]
// Suche in Kunde, Betreff, "Angefragt von" und WOID, ohne Gross-/Kleinschreibung
+74
View File
@@ -0,0 +1,74 @@
/* Spalte "Zeit" der Ticketliste: Arbeitszeit je Ticket, in Bloecken nachgeladen.
Nur Lesezugriffe. Eine Zelle ohne Ergebnis (laedt noch, Fehler, 0 min, abgeschnitten,
jenseits der ersten 200 Zeilen) zeigt immer "–", nie eine falsche Zahl. */
import { sumWorksheetMinutes } from './worksheetView'
import { stepLabel } from './timeSteps'
export const TIME_BLOCK = 100 // woids je Abfrage
export const TIME_QUERY_LIMIT = 5000 // Dokumente je Abfrage
export const TIME_MAX_ROWS = 200 // nur die ersten Zeilen bekommen eine Zeit
export const TIME_PARALLEL = 3 // gleichzeitige Abfragen
export function chunk(list, size) {
const out = []
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size))
return out
}
// Welche woids sind noch zu laden? Erste 200 Zeilen, ohne leere, doppelte und schon bekannte.
export function planTimeLoad(tickets, known = new Set()) {
const list = tickets || []
const seen = new Set()
const woids = []
for (const ticket of list.slice(0, TIME_MAX_ROWS)) {
const woid = String(ticket?.woid ?? '').trim()
if (!woid || seen.has(woid) || known.has(woid)) continue
seen.add(woid)
woids.push(woid)
}
return { blocks: chunk(woids, TIME_BLOCK), capped: list.length > TIME_MAX_ROWS }
}
// Minuten je woid eines Blocks. Wurde die Antwort abgeschnitten (Limit erreicht oder
// mehr Treffer als geliefert), ist keine Summe verlaesslich: null.
export function blockMinutes(woids, documents, total, limit = TIME_QUERY_LIMIT) {
const docs = documents || []
if (docs.length >= limit) return null
if (typeof total === 'number' && total > docs.length) return null
const byWoid = new Map()
for (const doc of docs) {
const key = String(doc?.woid ?? '').trim()
if (!byWoid.has(key)) byWoid.set(key, [])
byWoid.get(key).push(doc)
}
return Object.fromEntries(woids.map((woid) => [woid, sumWorksheetMinutes(byWoid.get(woid) || [])]))
}
// Worker-Pool: hoechstens `limit` gleichzeitig, Ergebnisse in Eingabereihenfolge, verwirft nie
export async function mapLimit(items, limit, fn) {
const results = new Array(items.length)
let next = 0
const worker = async () => {
while (next < items.length) {
const index = next
next += 1
try {
results[index] = { ok: true, value: await fn(items[index], index) }
} catch (error) {
results[index] = { ok: false, error }
}
}
}
await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker))
return results
}
export function minutesCellText(ticket, index, byWoid) {
if (index >= TIME_MAX_ROWS) return '–'
const woid = String(ticket?.woid ?? '').trim()
if (!woid) return '–'
const minutes = byWoid?.get(woid)
if (typeof minutes !== 'number' || !(minutes > 0)) return '–'
return stepLabel(minutes)
}
+77
View File
@@ -0,0 +1,77 @@
/* Anzeige-Helfer der Ticketseite und -liste.
Termin- und Faelligkeitsfelder sind Freitext aus Altdaten ("2.5.2026", "0930"): lesbare Werte
erscheinen normalisiert, alles andere als Rohtext - nie ein Rueckfall auf das Erstellungsdatum. */
import { formatTime } from './planboard'
import { isoToGermanDate, germanDateToIso } from './ticketForm'
const text = (value) => String(value ?? '').trim()
// "2.5.2026" -> "02.05.2026"; Unlesbares ("31.02.2026", ISO-Zeitstempel) bleibt als Text stehen
export function displayDate(value) {
return isoToGermanDate(germanDateToIso(value)) || text(value)
}
// "0930" oder "9:30" -> "09:30"; Unlesbares ("2560") bleibt als Text stehen
export function displayTime(value) {
return formatTime(value) || text(value)
}
// "02.05.2026, 09:30–10:30"; ohne Startdatum leer, das Ende steht nur mit Beginn
export function appointmentText(ticket) {
if (!text(ticket?.startDate)) return ''
let out = displayDate(ticket.startDate)
const start = displayTime(ticket.startTime)
if (start) {
out += ', ' + start
const end = displayTime(ticket.endTime)
if (end) out += '–' + end
}
return out
}
// "Faellig" nutzt nur die Frist, nie die Uhrzeit des Termins
export function dueText(ticket) {
return displayDate(ticket?.deadline)
}
// Nummer: woid, sonst die letzten fuenf Zeichen der $id (wie bisher in der Karte)
export function ticketNumber(ticket) {
return text(ticket?.woid) || text(ticket?.$id).slice(-5)
}
// Erstellzeitpunkt als Date, null wenn fehlt oder kaputt
export function createdDate(ticket) {
const raw = ticket?.$createdAt || ticket?.createdAt
if (!raw) return null
const date = new Date(raw)
return Number.isNaN(date.getTime()) ? null : date
}
// "Kenso Grimm" -> "Kenso G.", "Anna Maria Meier" -> "Anna M."
export function shortName(name) {
const words = text(name).split(/\s+/).filter(Boolean)
if (words.length < 2) return words[0] || ''
return `${words[0]} ${Array.from(words[words.length - 1])[0]}.`
}
// Zustaendig in der Liste: Person aus der Mitarbeiterliste, sonst der am Ticket gespeicherte Name
export function assigneeLabel(ticket, employees = []) {
const id = text(ticket?.assignedTo)
if (!id) return { text: 'Niemand', none: true }
const employee = employees.find((e) => e.userId === id)
return { text: shortName(employee?.displayName) || shortName(ticket.assignedName) || 'Unbekannt', none: false }
}
// Auswahl "Zustaendig": der aktuelle Wert bleibt immer waehlbar, auch wenn die Person nicht (mehr) in der Liste steht
export function assigneeOptions(ticket, employees = []) {
const options = [{ value: '', label: 'Nicht zugewiesen' }]
for (const e of employees) {
if (e.userId) options.push({ value: e.userId, label: e.displayName || e.email || e.userId })
}
const id = text(ticket?.assignedTo)
if (id && !options.some((o) => o.value === id)) {
options.push({ value: id, label: text(ticket.assignedName) || 'Unbekannte Person' })
}
return options
}
+21
View File
@@ -0,0 +1,21 @@
/* Ticket zu einer Adresse wie /tickets/<ref>: erst die Nummer (woid), dann die Dokument-ID.
Kennt kein Appwrite, die Seite reicht die Abfragen herein. */
const NOT_FOUND_CODES = [400, 404]
export async function lookupWorkorder(ref, { findByWoid, getById }) {
const key = String(ref ?? '').trim()
if (!key) return { status: 'notfound' }
const byWoid = await findByWoid(key)
if (byWoid) return { status: 'ok', ticket: byWoid }
try {
const byId = await getById(key)
return byId ? { status: 'ok', ticket: byId } : { status: 'notfound' }
} catch (error) {
// Appwrite: 404 = keine solche ID, 400 = keine gueltige ID
if (NOT_FOUND_CODES.includes(Number(error?.code))) return { status: 'notfound' }
throw error
}
}
+32
View File
@@ -0,0 +1,32 @@
/* Folgen eines neuen Arbeitsblatts.
Kennt kein Appwrite: die Seite reicht die Schreibzugriffe herein. */
// Akquise-Automatik: das Ergebnis im Arbeitsblatt steuert den Kundenstatus.
// "customer" = Zugesagt, "lost" = Abgesagt, sonst keine Aenderung.
export function acquisitionOutcome(ticket, newStatus) {
if (ticket?.type !== 'Akquise' || !ticket.customerId) return null
if (newStatus === 'Zugesagt') return 'customer'
if (newStatus === 'Abgesagt') return 'lost'
return null
}
/* 1. Arbeitsblatt anlegen. 2. Bei Erfolg den Ticket-Status nachziehen, wenn er abweicht.
3. Bei Akquise-Tickets den Kundenstatus setzen (Fehler werden verschluckt).
Rueckgabe ist das Ergebnis von createWorksheet. */
export async function createWorksheetFlow({ ticket, worksheetData, user, createWorksheet, updateTicket, updateCustomer, now = () => new Date() }) {
const result = await createWorksheet(worksheetData, user)
if (!result?.success) return result
const newStatus = worksheetData?.newStatus
if (newStatus !== ticket.status) {
await updateTicket(ticket.$id, { status: newStatus })
}
const customerStatus = acquisitionOutcome(ticket, newStatus)
if (customerStatus) {
try {
await updateCustomer(ticket.customerId, { customerStatus, updatedAt: now().toISOString() })
} catch { /* nicht kritisch */ }
}
return result
}
+75
View File
@@ -0,0 +1,75 @@
/* Anzeige- und Rechenlogik der Arbeitsblatt-Zeilen und der Kennzahlen darueber.
Kommentare haben keine Arbeitszeit und zaehlen nie mit. Die Zeit in der Zeile ist die
gespeicherte Arbeitszeit (totalTime); die Kennzahlen rechnen die Unterstuetzung mit. */
import { worksheetMinutes, supportMinutes, parseSupporters, firstName } from './support'
import { needsTimeEntry, worksheetTone, isGitWorksheet, serviceTypeLabel } from './ticketForm'
import { statusLabel } from './ticketStatus'
import { stepLabel } from './timeSteps'
import { displayDate, displayTime } from './ticketView'
// Summe der Arbeitszeit inklusive Unterstuetzung ueber alle Bloecke ohne Kommentare, optional gefiltert.
// Dieselbe Funktion rechnet die Spalte "Zeit" der Ticketliste.
export function sumWorksheetMinutes(worksheets, predicate = () => true) {
return (worksheets || [])
.filter((ws) => !ws.isComment && predicate(ws))
.reduce((sum, ws) => sum + worksheetMinutes(ws), 0)
}
// Kennzahlen ueber der Liste: Arbeitszeit gesamt, davon Abo-Stunden, Anzahl offener Git-Zeiten
export function worksheetSums(worksheets) {
const list = worksheets || []
return {
totalMinutes: sumWorksheetMinutes(list),
aboMinutes: sumWorksheetMinutes(list, (ws) => Boolean(ws.countsToPlan)),
openCount: list.filter(needsTimeEntry).length,
}
}
// Regel fuer den Abo-Schalter einer Zeile; ob die Seite ihn ueberhaupt anbietet (onTogglePlan), prueft die Komponente
export function showAboToggle(ws, aboHoursAllowed) {
return Boolean(!ws.isComment && Number(ws.totalTime) > 0 && (aboHoursAllowed || ws.countsToPlan))
}
// Wann: Startzweig, wenn ein Startdatum gesetzt ist, sonst der Endzweig. Altwerte bleiben Rohtext.
export function worksheetWhen(ws) {
const start = String(ws?.startDate ?? '').trim() !== ''
return start
? { date: displayDate(ws.startDate), time: displayTime(ws.startTime) }
: { date: displayDate(ws?.endDate), time: displayTime(ws?.endTime) }
}
export function worksheetKindLabel(ws) {
if (ws?.isComment && !isGitWorksheet(ws)) return 'Kommentar'
return serviceTypeLabel(ws?.serviceType) || ''
}
// Git-Pushes: orange, bis die Zeit nachgetragen ist, danach gruen; sonst kein Abzeichen
export function worksheetBadge(ws) {
const tone = worksheetTone(ws)
if (tone === 'git-open') return { tone: 'orange', text: 'Zeit offen' }
if (tone === 'git-done') return { tone: 'green', text: 'Zeit eingetragen' }
return null
}
// Nur wenn sich der Status geaendert hat
export function statusAfterLabel(ws) {
const next = ws?.newStatus
if (!next || next === ws.oldStatus) return ''
return `Status danach: ${statusLabel(next)}`
}
// Je unterstuetzender Person eine Zeile, z. B. "Unterstützung Anna · DNS · 30 min"
export function supportLines(ws) {
if (!ws || ws.isComment) return []
const minutes = stepLabel(supportMinutes(ws.totalTime))
return parseSupporters(ws.supporters).map((sup) =>
`${['Unterstützung', firstName(sup.name)].filter(Boolean).join(' ')} · ${sup.task} · ${minutes}`)
}
// Zeit rechts in der Zeile: Kommentare leer, ohne Arbeitszeit ein Strich
export function timeLabel(ws) {
if (ws?.isComment) return ''
const minutes = Number(ws?.totalTime) || 0
return minutes <= 0 ? '–' : stepLabel(minutes)
}