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:
commit
35e2761e68
9002 files changed
+12366
-1176888
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user