Unterstützung: Rechenregeln für 15-Minuten-Raster und Unterstützungszeit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
564fecdde4
commit
1497f7a4dc
4 files changed
+239
-1
No files matched your search
@@ -53,7 +53,7 @@ weil Claude den Appwrite-Schlüssel nicht verwenden darf.
|
|||||||
Betroffen: `CreateWorksheetModal` (neues Arbeitsblatt), `WorksheetTimesModal` („Zeiten bearbeiten“ und
|
Betroffen: `CreateWorksheetModal` (neues Arbeitsblatt), `WorksheetTimesModal` („Zeiten bearbeiten“ und
|
||||||
„Zeit nachtragen“ bei Git).
|
„Zeit nachtragen“ bei Git).
|
||||||
|
|
||||||
- Das Feld „Arbeitszeit“ wird eine Auswahl: 15 min, 30 min, 45 min, 1 h, 1 h 15 … bis 10 h (40 Werte).
|
- Das Feld „Arbeitszeit“ wird eine Auswahl: 15 min, 30 min, 45 min, 1 h, 1 h 15 … bis 12 h (48 Werte).
|
||||||
Kein freies Eintippen mehr.
|
Kein freies Eintippen mehr.
|
||||||
- Beginn und Ende bleiben freie Uhrzeiten (bei Git ist das Ende die Uhrzeit des Pushs). Die
|
- Beginn und Ende bleiben freie Uhrzeiten (bei Git ist das Ende die Uhrzeit des Pushs). Die
|
||||||
automatisch berechnete Zeit (`minutesBetween`) wird auf den nächsten 15-Minuten-Schritt
|
automatisch berechnete Zeit (`minutesBetween`) wird auf den nächsten 15-Minuten-Schritt
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/* Unterstuetzung bei Arbeitsblaettern.
|
||||||
|
Im Arbeitsblatt steht im Feld "supporters" eine JSON-Liste
|
||||||
|
[{ employeeId, name, task }]. Die Minuten werden nie gespeichert: jede
|
||||||
|
unterstuetzende Person bekommt die Haelfte der Arbeitszeit, aufgerundet auf
|
||||||
|
15 Minuten. Das Kundenportal rechnet mit derselben Regel
|
||||||
|
(server/services/worksheetMinutes.js). */
|
||||||
|
|
||||||
|
import { TIME_STEP } from './timeSteps'
|
||||||
|
import { isWorksheetCreator } from './ticketForm'
|
||||||
|
|
||||||
|
const MAX_TASK = 200
|
||||||
|
|
||||||
|
export function supportMinutes(totalTime) {
|
||||||
|
const total = Number(totalTime) || 0
|
||||||
|
if (total <= 0) return 0
|
||||||
|
return Math.ceil(total / 2 / TIME_STEP) * TIME_STEP
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nie eine Exception: kaputte Werte ergeben eine leere Liste, kaputte Eintraege fallen weg
|
||||||
|
export function parseSupporters(raw) {
|
||||||
|
let list = raw
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
if (!raw.trim()) return []
|
||||||
|
try {
|
||||||
|
list = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(list)) return []
|
||||||
|
const seen = new Set()
|
||||||
|
const result = []
|
||||||
|
for (const entry of list) {
|
||||||
|
if (!entry || typeof entry !== 'object') continue
|
||||||
|
const employeeId = String(entry.employeeId || '').trim()
|
||||||
|
const task = String(entry.task || '').trim().slice(0, MAX_TASK)
|
||||||
|
if (!employeeId || !task || seen.has(employeeId)) continue
|
||||||
|
seen.add(employeeId)
|
||||||
|
result.push({ employeeId, name: String(entry.name || '').trim(), task })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSupporters(list) {
|
||||||
|
return JSON.stringify((list || []).map(({ employeeId, name, task }) => ({ employeeId, name, task })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arbeitszeit des Blatts plus die Unterstuetzung aller Personen
|
||||||
|
export function worksheetMinutes(worksheet) {
|
||||||
|
const base = Number(worksheet?.totalTime) || 0
|
||||||
|
return base + parseSupporters(worksheet?.supporters).length * supportMinutes(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstName(name) {
|
||||||
|
return String(name || '').trim().split(/\s+/)[0] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Ich habe unterstuetzt": fremde Blaetter mit Arbeitszeit (Git-Pushes erst nach "Zeit nachtragen")
|
||||||
|
export function canAddOwnSupport(worksheet, user, employee) {
|
||||||
|
if (!user?.$id || !worksheet) return false
|
||||||
|
if (worksheet.isComment || !(Number(worksheet.totalTime) > 0)) return false
|
||||||
|
return !isWorksheetCreator(worksheet, user, employee)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertSupporter(list, entry) {
|
||||||
|
const current = list || []
|
||||||
|
return current.some((s) => s.employeeId === entry.employeeId)
|
||||||
|
? current.map((s) => (s.employeeId === entry.employeeId ? { ...s, ...entry } : s))
|
||||||
|
: [...current, entry]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSupporter(list, employeeId) {
|
||||||
|
return (list || []).filter((s) => s.employeeId !== employeeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung im Formular des Erstellers; null = in Ordnung
|
||||||
|
export function validateSupporters(list, creatorUserId) {
|
||||||
|
const rows = list || []
|
||||||
|
if (rows.some((s) => !String(s.employeeId || '').trim())) return 'Bitte in jeder Zeile eine Person auswählen.'
|
||||||
|
if (rows.some((s) => !String(s.task || '').trim())) return 'Bitte bei jeder Person angeben, wobei sie unterstützt hat.'
|
||||||
|
const ids = rows.map((s) => s.employeeId)
|
||||||
|
if (new Set(ids).size !== ids.length) return 'Jede Person nur einmal eintragen.'
|
||||||
|
if (creatorUserId && ids.includes(creatorUserId)) return 'Du kannst dich nicht selbst als Unterstützung eintragen.'
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/* Arbeitszeit wird nur in 15-Minuten-Schritten erfasst (15 min bis 12 h).
|
||||||
|
Aus Beginn und Ende berechnete Zeiten werden auf den naechsten Schritt aufgerundet.
|
||||||
|
12 h ist auch das Maximum der Push-Zeitmail im Kundenportal. */
|
||||||
|
|
||||||
|
export const TIME_STEP = 15
|
||||||
|
export const MAX_STEP_MINUTES = 720
|
||||||
|
|
||||||
|
// 50 -> 60, 61 -> 75; keine oder negative Dauer -> 15 (kleinster Schritt)
|
||||||
|
export function roundUpToStep(minutes) {
|
||||||
|
const value = Number(minutes)
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return TIME_STEP
|
||||||
|
return Math.ceil(value / TIME_STEP) * TIME_STEP
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidStep(minutes) {
|
||||||
|
if (typeof minutes !== 'number' && typeof minutes !== 'string') return false
|
||||||
|
const value = Number(minutes)
|
||||||
|
return Number.isInteger(value) && value > 0 && value % TIME_STEP === 0 && value <= MAX_STEP_MINUTES
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15 -> "15 min", 60 -> "1 h", 75 -> "1 h 15 min"
|
||||||
|
export function stepLabel(minutes) {
|
||||||
|
const value = Math.max(0, Number(minutes) || 0)
|
||||||
|
const hours = Math.floor(value / 60)
|
||||||
|
const mins = value % 60
|
||||||
|
if (!hours) return `${mins} min`
|
||||||
|
return mins ? `${hours} h ${mins} min` : `${hours} h`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auswahl 15 min … 12 h. Ein alter, laengerer Wert im Raster bleibt waehlbar.
|
||||||
|
export function stepOptions(current) {
|
||||||
|
const options = []
|
||||||
|
for (let m = TIME_STEP; m <= MAX_STEP_MINUTES; m += TIME_STEP) options.push({ value: m, label: stepLabel(m) })
|
||||||
|
const value = Number(current)
|
||||||
|
if (Number.isInteger(value) && value > MAX_STEP_MINUTES && value % TIME_STEP === 0) {
|
||||||
|
options.push({ value, label: stepLabel(value) })
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const { TIME_STEP, MAX_STEP_MINUTES, roundUpToStep, isValidStep, stepOptions, stepLabel } = await loadModule('src/lib/timeSteps.js')
|
||||||
|
const {
|
||||||
|
supportMinutes, parseSupporters, serializeSupporters, worksheetMinutes, firstName,
|
||||||
|
canAddOwnSupport, upsertSupporter, removeSupporter, validateSupporters,
|
||||||
|
} = await loadModule('src/lib/support.js')
|
||||||
|
|
||||||
|
const andrej = { employeeId: 'a', name: 'Andrej Stevanovski', task: 'Farbkonzept' }
|
||||||
|
const nico = { employeeId: 'n', name: 'Nico Rech', task: 'Texte' }
|
||||||
|
|
||||||
|
test('Raster: 15 Minuten bis 12 Stunden', () => {
|
||||||
|
assert.equal(TIME_STEP, 15)
|
||||||
|
assert.equal(MAX_STEP_MINUTES, 720)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('roundUpToStep rundet auf, 0 und leer werden 15', () => {
|
||||||
|
assert.equal(roundUpToStep(0), 15)
|
||||||
|
assert.equal(roundUpToStep(null), 15)
|
||||||
|
assert.equal(roundUpToStep(-10), 15)
|
||||||
|
assert.equal(roundUpToStep(50), 60)
|
||||||
|
assert.equal(roundUpToStep(60), 60)
|
||||||
|
assert.equal(roundUpToStep(61), 75)
|
||||||
|
assert.equal(roundUpToStep(70), 75)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isValidStep: nur ganze Vielfache von 15 bis 720', () => {
|
||||||
|
for (const ok of [15, 45, 720]) assert.equal(isValidStep(ok), true, String(ok))
|
||||||
|
for (const bad of [70, 0, 735, 'x', 7.5, -15, null]) assert.equal(isValidStep(bad), false, String(bad))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stepOptions: 48 Werte mit deutschen Beschriftungen', () => {
|
||||||
|
const opts = stepOptions()
|
||||||
|
assert.equal(opts.length, 48)
|
||||||
|
assert.deepEqual(opts[0], { value: 15, label: '15 min' })
|
||||||
|
assert.deepEqual(opts[3], { value: 60, label: '1 h' })
|
||||||
|
assert.deepEqual(opts[4], { value: 75, label: '1 h 15 min' })
|
||||||
|
assert.deepEqual(opts[47], { value: 720, label: '12 h' })
|
||||||
|
// Ein gueltiger Wert ueber 12 h (altes Blatt) bleibt waehlbar
|
||||||
|
assert.equal(stepOptions(900).length, 49)
|
||||||
|
assert.equal(stepOptions(60).length, 48)
|
||||||
|
assert.equal(stepLabel(135), '2 h 15 min')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('supportMinutes: Hälfte, aufgerundet auf 15', () => {
|
||||||
|
const cases = [[15, 15], [30, 15], [45, 30], [60, 30], [75, 45], [70, 45], [0, 0], [-5, 0], ['60', 30], [null, 0]]
|
||||||
|
for (const [total, expected] of cases) assert.equal(supportMinutes(total), expected, `${total}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseSupporters ist fehlertolerant', () => {
|
||||||
|
assert.deepEqual(parseSupporters('kaputt'), [])
|
||||||
|
assert.deepEqual(parseSupporters('{}'), [])
|
||||||
|
assert.deepEqual(parseSupporters(null), [])
|
||||||
|
assert.deepEqual(parseSupporters(''), [])
|
||||||
|
const raw = '[{"employeeId":"a","name":"Andrej S","task":" Farbe "},{"employeeId":"a","name":"x","task":"y"},{"employeeId":"b","name":"Nico"},null,"x"]'
|
||||||
|
assert.deepEqual(parseSupporters(raw), [{ employeeId: 'a', name: 'Andrej S', task: 'Farbe' }])
|
||||||
|
assert.equal(parseSupporters([{ employeeId: 'a', name: 'A', task: 'x'.repeat(250) }])[0].task.length, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('serializeSupporters und parseSupporters passen zusammen', () => {
|
||||||
|
assert.deepEqual(parseSupporters(serializeSupporters([andrej, nico])), [andrej, nico])
|
||||||
|
assert.equal(serializeSupporters([]), '[]')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('worksheetMinutes: Arbeitszeit plus je Person die Unterstützung', () => {
|
||||||
|
assert.equal(worksheetMinutes({ totalTime: 60, supporters: serializeSupporters([andrej, nico]) }), 120)
|
||||||
|
assert.equal(worksheetMinutes({ totalTime: 70, supporters: serializeSupporters([andrej]) }), 115)
|
||||||
|
assert.equal(worksheetMinutes({ totalTime: 45 }), 45)
|
||||||
|
assert.equal(worksheetMinutes({ totalTime: 0, supporters: serializeSupporters([andrej]) }), 0)
|
||||||
|
assert.equal(worksheetMinutes({ totalTime: 60, supporters: 'kaputt' }), 60)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('firstName', () => {
|
||||||
|
assert.equal(firstName('Andrej Stevanovski'), 'Andrej')
|
||||||
|
assert.equal(firstName(' Nico '), 'Nico')
|
||||||
|
assert.equal(firstName(''), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('canAddOwnSupport: nur fremde Blätter mit Arbeitszeit', () => {
|
||||||
|
const kenso = { $id: 'kenso-admin', name: 'Kenso Grimm' }
|
||||||
|
const kensoEmp = { shortcode: 'KNSO', displayName: 'Kenso Grimm' }
|
||||||
|
const andrejUser = { $id: 'andrej', name: 'Andrej Stevanovski' }
|
||||||
|
const andrejEmp = { shortcode: 'ANDJ', displayName: 'Andrej Stevanovski' }
|
||||||
|
const fremd = { employeeId: 'justin', totalTime: 60, isComment: false, serviceType: 'Remote' }
|
||||||
|
assert.equal(canAddOwnSupport(fremd, kenso, kensoEmp), true)
|
||||||
|
assert.equal(canAddOwnSupport({ ...fremd, employeeId: 'kenso-admin' }, kenso, kensoEmp), false)
|
||||||
|
assert.equal(canAddOwnSupport({ ...fremd, isComment: true }, kenso, kensoEmp), false)
|
||||||
|
assert.equal(canAddOwnSupport({ ...fremd, totalTime: 0 }, kenso, kensoEmp), false)
|
||||||
|
assert.equal(canAddOwnSupport(fremd, null, null), false)
|
||||||
|
const gitOffen = { employeeId: 'gitea', employeeName: 'knso', serviceType: 'GIT', isComment: true, totalTime: 0 }
|
||||||
|
assert.equal(canAddOwnSupport(gitOffen, andrejUser, andrejEmp), false)
|
||||||
|
const gitFertig = { ...gitOffen, isComment: false, totalTime: 45, startTime: '1400' }
|
||||||
|
assert.equal(canAddOwnSupport(gitFertig, kenso, kensoEmp), false)
|
||||||
|
assert.equal(canAddOwnSupport(gitFertig, andrejUser, andrejEmp), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('upsertSupporter und removeSupporter', () => {
|
||||||
|
const list = upsertSupporter([andrej], nico)
|
||||||
|
assert.deepEqual(list, [andrej, nico])
|
||||||
|
assert.deepEqual(upsertSupporter(list, { ...andrej, task: 'Logo' }), [{ ...andrej, task: 'Logo' }, nico])
|
||||||
|
assert.deepEqual(removeSupporter(list, 'a'), [nico])
|
||||||
|
assert.deepEqual(removeSupporter(list, 'x'), list)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validateSupporters', () => {
|
||||||
|
assert.equal(validateSupporters([andrej, nico], 'justin'), null)
|
||||||
|
assert.equal(validateSupporters([], 'justin'), null)
|
||||||
|
assert.equal(validateSupporters([{ ...andrej, task: ' ' }], 'justin'), 'Bitte bei jeder Person angeben, wobei sie unterstützt hat.')
|
||||||
|
assert.equal(validateSupporters([{ ...andrej, employeeId: '' }], 'justin'), 'Bitte in jeder Zeile eine Person auswählen.')
|
||||||
|
assert.equal(validateSupporters([andrej, { ...andrej, task: 'x' }], 'justin'), 'Jede Person nur einmal eintragen.')
|
||||||
|
assert.equal(validateSupporters([andrej], 'a'), 'Du kannst dich nicht selbst als Unterstützung eintragen.')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user