Tickets: ticketPath, Listenzustand in der URL (Reiter, Art, Dringend, Suche, limit), woid-Umleitung, Zeilenklick-Helfer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 06:02:54 +00:00
1 parent f3b73ca1dd
commit 54fc44ed11
7 files changed
+249 -1

No files matched your search

+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))
}
+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 "/tickets?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 || 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'
}
+1 -1
View File
@@ -106,7 +106,7 @@ export const TYPE_FILTERS = [
{ id: 'Migration', label: 'Migration', types: ['Migration'] }, { id: 'Migration', label: 'Migration', types: ['Migration'] },
{ id: 'Project', label: 'Projekt', types: ['Project'] }, { id: 'Project', label: 'Projekt', types: ['Project'] },
{ id: 'Akquise', label: 'Akquise', types: ['Akquise'] }, { 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 // Suche in Kunde, Betreff, "Angefragt von" und WOID, ohne Gross-/Kleinschreibung
+36
View File
@@ -0,0 +1,36 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { isRowActivation } = await loadModule('src/lib/rowClick.js')
const TAGS = ['a', 'button', 'select', 'input', 'textarea', 'label']
// Ereignis ohne DOM: closest liefert nur dann einen Treffer, wenn der Selektor das Tag nennt
function click({ target = 'td', outside = false, ...over } = {}) {
const row = { contains: (el) => el?.inRow === true }
const hit = TAGS.includes(target) ? { inRow: !outside } : null
return {
target: { closest: (sel) => (hit && sel.split(',').map((s) => s.trim()).includes(target) ? hit : null) },
currentTarget: row, button: 0, defaultPrevented: false, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...over,
}
}
test('Linksklick auf Zelle oder Zeile aktiviert die Zeile', () => {
assert.equal(isRowActivation(click()), true)
assert.equal(isRowActivation(click({ target: 'tr' })), true)
})
test('Klick auf a, button, select, input, textarea oder label aktiviert nicht', () => {
for (const tag of TAGS) assert.equal(isRowActivation(click({ target: tag })), false, tag)
})
test('Mittelklick, Modifikatortasten und behandelte Klicks aktivieren nicht', () => {
assert.equal(isRowActivation(click({ button: 1 })), false)
for (const key of ['metaKey', 'ctrlKey', 'shiftKey', 'altKey']) assert.equal(isRowActivation(click({ [key]: true })), false, key)
assert.equal(isRowActivation(click({ defaultPrevented: true })), false)
})
test('ein interaktiver Vorfahre außerhalb der Zeile zählt nicht', () => {
assert.equal(isRowActivation(click({ target: 'label', outside: true })), true)
})
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const m = await loadModule('src/lib/ticketListUrl.js')
const { LIST_TABS, LIST_DEFAULTS, parseListParams: parse, serializeListParams: ser, withFilter, nextLimit, listFilters, countFilters, woidRedirectTarget } = m
const { TYPE_FILTERS } = await loadModule('src/lib/ticketStatus.js')
test('Reiter und Art-Auswahl nach Spec', () => {
assert.deepEqual(LIST_TABS.map((t) => [t.id, t.label]), [['aktiv', 'Aktiv'], ['open', 'Offen'], ['working', 'In Arbeit'], ['waiting', 'Wartet auf Kunde'], ['review', 'Prüfung'], ['done', 'Erledigt'], ['cancelled', 'Storniert'], ['alle', 'Alle']])
assert.deepEqual(TYPE_FILTERS.map((f) => f.label), ['Aufträge', 'Webpage', 'Migration', 'Projekt', 'Akquise', 'Aufträge und Akquise'])
})
test('parse: Standard, gültige und ungültige Werte', () => {
assert.deepEqual(parse(''), LIST_DEFAULTS)
assert.deepEqual(parse('?status=done&art=all&dringend=1&q=%20Müller%20&limit=30'), { tab: 'done', typeId: 'all', urgentOnly: true, search: 'Müller', limit: 30 })
assert.deepEqual(parse('status=foo&art=bar&dringend=ja&limit=abc'), LIST_DEFAULTS)
for (const [raw, want] of [['5', 10], ['0', 10], ['25', 25], ['500', 500], ['9999', 500], ['-3', 10], ['1e3', 10], ['', 10]]) assert.equal(parse('limit=' + raw).limit, want, raw)
})
test('serialize: Standardwerte werden nicht geschrieben, Rundlauf', () => {
assert.equal(ser(LIST_DEFAULTS).toString(), '')
assert.equal(ser({ ...LIST_DEFAULTS, tab: 'alle', limit: 20 }).toString(), 'status=alle&limit=20')
assert.equal(ser({ ...LIST_DEFAULTS, urgentOnly: true, search: 'a b' }).toString(), 'dringend=1&q=a+b')
for (const s of [LIST_DEFAULTS, { tab: 'done', typeId: 'Akquise', urgentOnly: true, search: 'x y', limit: 40 }]) assert.deepEqual(parse(ser(s)), s)
})
test('Filteränderung setzt limit zurück, Mehr laden +10 bis 500', () => {
for (const patch of [{ tab: 'done' }, { typeId: 'all' }, { urgentOnly: true }, { search: 'x' }]) assert.equal(withFilter({ ...LIST_DEFAULTS, limit: 50 }, patch).limit, 10)
assert.deepEqual([10, 495, 500].map((limit) => nextLimit({ limit })), [20, 500, 500])
})
test('listFilters und countFilters', () => {
assert.deepEqual(listFilters(LIST_DEFAULTS), { status: ['Open', 'Occupied', 'Assigned', 'Added Info', 'In Kontakt', 'Awaiting', 'Halted', 'In Test'], type: [], excludeType: 'Akquise', priority: [], search: undefined, limit: 10 })
assert.deepEqual(listFilters({ ...LIST_DEFAULTS, tab: 'alle', typeId: 'Akquise', urgentOnly: true, search: 'abc' }), { status: [], type: ['Akquise'], excludeType: undefined, priority: [3, 4], search: 'abc', limit: 10 })
assert.deepEqual(listFilters({ ...LIST_DEFAULTS, tab: 'done' }).status, ['Closed', 'Zugesagt'])
const s = { ...LIST_DEFAULTS, typeId: 'Webpage', urgentOnly: true, search: 'zzz', limit: 80 }
assert.deepEqual(countFilters(s, 'review'), { status: ['In Test'], type: ['Webpage'], excludeType: undefined, priority: [3, 4] })
for (const t of LIST_TABS) assert.deepEqual(countFilters(s, t.id).status, listFilters({ ...s, tab: t.id }).status, t.id)
})
test('woid-Umleitung hat Vorrang', () => {
assert.equal(woidRedirectTarget('?woid=100131'), '/tickets/100131')
assert.equal(woidRedirectTarget('?woid=%20100131%20&status=done&q=x'), '/tickets/100131')
assert.equal(woidRedirectTarget('?woid=a%2Fb'), '/tickets/a%2Fb')
for (const none of ['', '?woid=', '?status=done']) assert.equal(woidRedirectTarget(none), null)
})
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { ticketPath, ticketRef, resolveTicketTab, listBackPath } = await loadModule('src/lib/ticketRoutes.js')
test('ticketPath: woid, Zahl, $id, Reiter', () => {
assert.equal(ticketPath('100131'), '/tickets/100131')
assert.equal(ticketPath(100131), '/tickets/100131')
assert.equal(ticketPath('100131', 'details'), '/tickets/100131')
assert.equal(ticketPath('100131', 'arbeitsblaetter'), '/tickets/100131/arbeitsblaetter')
assert.equal(ticketPath('100131', 'unbekannt'), '/tickets/100131')
assert.equal(ticketPath('66f1c2a9000b1c2d3e4f'), '/tickets/66f1c2a9000b1c2d3e4f')
})
test('ticketPath: Sonderzeichen kodiert, leeres ref ergibt /tickets', () => {
assert.equal(ticketPath('a/b c?d#e'), '/tickets/a%2Fb%20c%3Fd%23e')
for (const empty of ['', ' ', null, undefined]) {
assert.equal(ticketPath(empty), '/tickets')
assert.equal(ticketPath(empty, 'arbeitsblaetter'), '/tickets')
}
})
test('ticketRef: woid vor $id, nie die Kurznummer', () => {
assert.equal(ticketRef({ woid: '100131', $id: 'abc' }), '100131')
assert.equal(ticketRef({ woid: '', $id: 'abcdefghij' }), 'abcdefghij')
assert.equal(ticketRef(null), '')
})
test('resolveTicketTab: unbekannt ergibt null', () => {
assert.equal(resolveTicketTab(undefined), 'details')
assert.equal(resolveTicketTab('arbeitsblaetter'), 'arbeitsblaetter')
for (const bad of ['Details', 'worksheets', '', 'x']) assert.equal(resolveTicketTab(bad), null)
})
test('listBackPath: nur Suchteile mit ?', () => {
assert.equal(listBackPath({ listSearch: '?status=done&q=x' }), '/tickets?status=done&q=x')
for (const bad of [undefined, null, {}, { listSearch: '' }, { listSearch: '?' }, { listSearch: 'evil' }, { listSearch: 42 }]) assert.equal(listBackPath(bad), '/tickets')
})