From 4b6ab7505a1b8466e739067fe1b23f8580fc31cc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 09:12:49 +0000 Subject: [PATCH] Kunden: Filter, Zaehler und Abzeichen als reine Funktionen (lib/customerList.js, Tests) Co-Authored-By: Claude Sonnet 5.5 --- src/lib/customerList.js | 82 ++++++++++++++++++++++++++++++++ src/lib/leads.js | 12 +---- tests/unit/customerList.test.mjs | 73 ++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 src/lib/customerList.js create mode 100644 tests/unit/customerList.test.mjs diff --git a/src/lib/customerList.js b/src/lib/customerList.js new file mode 100644 index 0000000..500b956 --- /dev/null +++ b/src/lib/customerList.js @@ -0,0 +1,82 @@ +// Reine Funktionen der Kundenliste und der Kundenseite: Stufe, Filter, Zähler und Abzeichen. +// Ohne Importe, damit Liste, Zähler und Tests dieselbe Logik benutzen. + +export function customerStage(c) { + const s = (c?.customerStatus || '').toLowerCase() + if (s === 'lead') return 'lead' + if (s === 'lost') return 'lost' + return 'customer' // Bestandsdaten ohne Status gelten als fester Kunde +} + +/** Archiviert = aus der Standardansicht ausgeblendet (orthogonal zur CRM-Stufe). */ +export function isArchived(c) { + return Boolean(c?.archived) +} + +/** Reiter der Kundenliste in Anzeigereihenfolge. */ +export const CUSTOMER_TABS = [ + { id: 'customer', label: 'Feste Kunden' }, + { id: 'lead', label: 'Leads' }, + { id: 'archived', label: 'Archiviert' }, + { id: 'all', label: 'Alle' }, +] + +/** + * Filtert die Kundenliste nach Reiter und Suchtext. Die Reihenfolge der Eingabe bleibt. + * Archivierte erscheinen nur im Reiter `archived`. Die Stufe `lost` erscheint nur im Reiter `all`. + */ +export function filterCustomers(customers, { stage = 'customer', search = '' } = {}) { + const s = String(search || '').toLowerCase() + const matchesStage = (c) => { + const archived = isArchived(c) + if (stage === 'archived') return archived + if (archived) return false + if (stage === 'all') return true + if (stage === 'customer' || stage === 'lead') return customerStage(c) === stage + return false + } + return (customers || []).filter((c) => { + if (!matchesStage(c)) return false + if (!s) return true + return `${c.name || ''} ${c.code || ''} ${c.location || ''} ${c.email || ''} ${c.contactName || ''}`.toLowerCase().includes(s) + }) +} + +/** Zähler je Reiter, berechnet mit derselben Filterfunktion wie die Liste (beachtet den Suchtext). */ +export function stageCounts(customers, search = '') { + const counts = {} + for (const tab of CUSTOMER_TABS) counts[tab.id] = filterCustomers(customers, { stage: tab.id, search }).length + return counts +} + +const STAGE_LABELS = { customer: 'Fester Kunde', lead: 'Lead', lost: 'Abgesagt' } + +export function stageLabel(stage) { + return STAGE_LABELS[stage] || '' +} + +/** + * Abzeichen eines Kunden in fester Reihenfolge: Wichtig, Stufe, Archiviert. + * Die Stufe „Fester Kunde“ ist kein Abzeichen. Mit `stage: false` entfällt die Stufe (Kundenseite, dort steht sie in der Eyebrow). + */ +export function customerBadges(c, { stage = true } = {}) { + const badges = [] + if (c?.importantCustomer) badges.push({ key: 'important', label: 'Wichtig', tone: 'warn' }) + if (stage) { + const st = customerStage(c) + if (st === 'lead') badges.push({ key: 'lead', label: stageLabel('lead'), tone: 'muted' }) + else if (st === 'lost') badges.push({ key: 'lost', label: stageLabel('lost'), tone: 'plain' }) + } + if (isArchived(c)) badges.push({ key: 'archived', label: 'Archiviert', tone: 'plain' }) + return badges +} + +/** Kleine Zeile über dem Titel der Kundenseite: „Kundennummer · Stufe“. */ +export function customerEyebrow(c) { + return [String(c?.code || '').trim(), stageLabel(customerStage(c))].filter(Boolean).join(' · ') +} + +/** Titel der Kundenseite: Name, sonst E-Mail, sonst Gedankenstrich. */ +export function customerTitle(c) { + return c?.name || c?.email || '–' +} diff --git a/src/lib/leads.js b/src/lib/leads.js index 7393873..1b9c4bc 100644 --- a/src/lib/leads.js +++ b/src/lib/leads.js @@ -5,17 +5,7 @@ export function isInvoiceReady(c) { return Boolean(c && c.name && c.email && c.street && c.city && c.postalCode) } -export function customerStage(c) { - const s = (c?.customerStatus || '').toLowerCase() - if (s === 'lead') return 'lead' - if (s === 'lost') return 'lost' - return 'customer' // Bestandsdaten ohne Status gelten als fester Kunde -} - -/** Archiviert = aus der Standardansicht ausgeblendet (orthogonal zur CRM-Stufe). */ -export function isArchived(c) { - return Boolean(c?.archived) -} +export { customerStage, isArchived, filterCustomers, stageCounts } from './customerList' /** Kunde archivieren / wiederherstellen. */ export async function setCustomerArchived(customer, archived) { diff --git a/tests/unit/customerList.test.mjs b/tests/unit/customerList.test.mjs new file mode 100644 index 0000000..188d0d1 --- /dev/null +++ b/tests/unit/customerList.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { CUSTOMER_TABS, customerStage, isArchived, filterCustomers, stageCounts, customerBadges, stageLabel, customerEyebrow, customerTitle } = + await loadModule('src/lib/customerList.js') + +const A = { $id: 'a', name: 'Alpha GmbH', code: 'K-1', location: 'Kaiserslautern', email: 'info@alpha.de', contactName: 'Anna Alt', customerStatus: 'customer' } +const B = { $id: 'b', name: 'Beta', customerStatus: 'lead' } +const C = { $id: 'c', name: 'Gamma', customerStatus: 'lost' } +const D = { $id: 'd', name: 'Delta' } // Bestandsdaten ohne Status gelten als fester Kunde +const E = { $id: 'e', name: 'Epsilon', customerStatus: 'lead', archived: true, importantCustomer: true } +const F = { $id: 'f', name: 'Zeta', customerStatus: 'customer', archived: true } +const ALL = [A, B, C, D, E, F] +const ids = (list) => list.map((c) => c.$id) + +test('CUSTOMER_TABS, customerStage, isArchived', () => { + assert.deepEqual(CUSTOMER_TABS, [ + { id: 'customer', label: 'Feste Kunden' }, { id: 'lead', label: 'Leads' }, + { id: 'archived', label: 'Archiviert' }, { id: 'all', label: 'Alle' }, + ]) + assert.equal(customerStage({ customerStatus: 'LEAD' }), 'lead') + assert.equal(customerStage({ customerStatus: 'Lost' }), 'lost') + for (const c of [{}, { customerStatus: '' }, { customerStatus: 'sonstwas' }, null, undefined]) assert.equal(customerStage(c), 'customer') + assert.equal(isArchived({ archived: true }), true) + for (const c of [{}, { archived: false }, null, undefined]) assert.equal(isArchived(c), false) +}) + +test('filterCustomers: jeder Reiter', () => { + assert.deepEqual(ids(filterCustomers(ALL, { stage: 'customer' })), ['a', 'd']) + assert.deepEqual(ids(filterCustomers(ALL, { stage: 'lead' })), ['b']) + assert.deepEqual(ids(filterCustomers(ALL, { stage: 'archived' })), ['e', 'f']) + assert.deepEqual(ids(filterCustomers(ALL, { stage: 'all' })), ['a', 'b', 'c', 'd']) + assert.deepEqual(ids(filterCustomers(ALL)), ['a', 'd']) + assert.deepEqual(ids(filterCustomers(ALL, { stage: 'x' })), []) + assert.deepEqual(filterCustomers(undefined, { stage: 'all' }), []) +}) + +test('filterCustomers: Suche in Name, Code, Standort, E-Mail und Ansprechpartner', () => { + const find = (search, stage = 'all') => ids(filterCustomers(ALL, { stage, search })) + for (const s of ['ALPHA', 'k-1', 'kaisers', 'info@alpha', 'anna']) assert.deepEqual(find(s), ['a'], s) + assert.deepEqual(find('xyz'), []) + assert.deepEqual(find('eta', 'lead'), ['b']) + assert.deepEqual(find(''), ['a', 'b', 'c', 'd']) + assert.deepEqual(ids(filterCustomers([{ $id: 'n' }], { stage: 'all', search: 'undefined' })), []) +}) + +test('stageCounts: dieselbe Filterfunktion wie die Liste', () => { + assert.deepEqual(stageCounts(ALL), { customer: 2, lead: 1, archived: 2, all: 4 }) + assert.deepEqual(stageCounts(ALL, 'eta'), { customer: 0, lead: 1, archived: 1, all: 1 }) + assert.deepEqual(stageCounts([]), { customer: 0, lead: 0, archived: 0, all: 0 }) +}) + +test('customerBadges: Reihenfolge, Töne, Option stage', () => { + assert.deepEqual(customerBadges(E), [ + { key: 'important', label: 'Wichtig', tone: 'warn' }, + { key: 'lead', label: 'Lead', tone: 'muted' }, + { key: 'archived', label: 'Archiviert', tone: 'plain' }, + ]) + assert.deepEqual(customerBadges(C), [{ key: 'lost', label: 'Abgesagt', tone: 'plain' }]) + for (const c of [A, D, null]) assert.deepEqual(customerBadges(c), []) + assert.deepEqual(customerBadges(E, { stage: false }).map((b) => b.key), ['important', 'archived']) +}) + +test('stageLabel, customerEyebrow, customerTitle', () => { + assert.deepEqual(['customer', 'lead', 'lost'].map(stageLabel), ['Fester Kunde', 'Lead', 'Abgesagt']) + assert.equal(customerEyebrow(A), 'K-1 · Fester Kunde') + assert.equal(customerEyebrow(B), 'Lead') + assert.equal(customerEyebrow({ code: ' ', customerStatus: 'lost' }), 'Abgesagt') + assert.equal(customerTitle(A), 'Alpha GmbH') + assert.equal(customerTitle({ email: 'x@y.de' }), 'x@y.de') + assert.equal(customerTitle({}), '–') +})