feat(domains): Kunden, Kontakte aus AutoDNS mit Zuordnung und Domainliste
- Kunden mit Markierung eigene Firma, Kontakte live aus AutoDNS (seitenweise, 60 s Zwischenspeicher), Zuordnung je System - Kontakt anlegen (Person/Organisation) und einem oder mehreren Kunden zuordnen, nur mit Verwalten - Domainliste mit Kunde (ueber den Inhaber-Kontakt), Inhaber, Ablaufdatum, Status; Filter und Gruppierung nach Kunde - Mandantenschutz-Inventar um die beiden neuen Zugriffspaare ergaenzt Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ALL_CUSTOMERS,
|
||||
filterByCustomer,
|
||||
groupByCustomer,
|
||||
matchesText,
|
||||
UNASSIGNED,
|
||||
} from './group-by-customer';
|
||||
|
||||
interface Item {
|
||||
name: string;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
}
|
||||
|
||||
const customers = [
|
||||
{ id: 'c-z', name: 'Zeller GmbH', isOwnCompany: false },
|
||||
{ id: 'c-ae', name: 'Äpfel AG', isOwnCompany: false },
|
||||
{ id: 'c-own', name: 'Unsere Firma', isOwnCompany: true },
|
||||
{ id: 'c-b', name: 'Bauer', isOwnCompany: false },
|
||||
];
|
||||
|
||||
const items: Item[] = [
|
||||
{ name: 'a.de', customerId: null, customerName: null },
|
||||
{ name: 'b.de', customerId: 'c-z', customerName: 'Zeller GmbH' },
|
||||
{ name: 'c.de', customerId: 'c-own', customerName: 'Unsere Firma' },
|
||||
{ name: 'd.de', customerId: 'c-ae', customerName: 'Äpfel AG' },
|
||||
{ name: 'e.de', customerId: 'c-b', customerName: 'Bauer' },
|
||||
{ name: 'f.de', customerId: 'c-z', customerName: 'Zeller GmbH' },
|
||||
];
|
||||
|
||||
describe('groupByCustomer', () => {
|
||||
it('eigene Firma zuerst, dann deutsch sortiert, "Nicht zugeordnet" zuletzt', () => {
|
||||
const groups = groupByCustomer(items, customers);
|
||||
expect(groups.map((g) => g.key)).toEqual(['c-own', 'c-ae', 'c-b', 'c-z', UNASSIGNED]);
|
||||
expect(groups[0].isOwnCompany).toBe(true);
|
||||
expect(groups[4].name).toBeNull();
|
||||
expect(groups.find((g) => g.key === 'c-z')?.items.map((i) => i.name)).toEqual(['b.de', 'f.de']);
|
||||
});
|
||||
|
||||
it('laesst leere Gruppen weg und veraendert die Eingabe nicht', () => {
|
||||
const copy = JSON.parse(JSON.stringify(items));
|
||||
const groups = groupByCustomer(items.slice(0, 1), customers);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].key).toBe(UNASSIGNED);
|
||||
groupByCustomer(items, customers);
|
||||
expect(items).toEqual(copy);
|
||||
});
|
||||
|
||||
it('ein Kunde, den die Kundenliste nicht kennt, behaelt seinen Namen aus dem Eintrag', () => {
|
||||
const groups = groupByCustomer(
|
||||
[{ name: 'x.de', customerId: 'c-weg', customerName: 'Gelöscht AG' }],
|
||||
customers,
|
||||
);
|
||||
expect(groups[0].name).toBe('Gelöscht AG');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterByCustomer', () => {
|
||||
it('all, unassigned und eine Kunden-Id', () => {
|
||||
expect(filterByCustomer(items, ALL_CUSTOMERS)).toHaveLength(6);
|
||||
expect(filterByCustomer(items, UNASSIGNED).map((i) => i.name)).toEqual(['a.de']);
|
||||
expect(filterByCustomer(items, 'c-z').map((i) => i.name)).toEqual(['b.de', 'f.de']);
|
||||
expect(filterByCustomer(items, 'c-gibt-es-nicht')).toEqual([]);
|
||||
});
|
||||
|
||||
it('gibt bei "all" eine Kopie zurueck, nicht die Eingabe', () => {
|
||||
expect(filterByCustomer(items, ALL_CUSTOMERS)).not.toBe(items);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesText', () => {
|
||||
const fields = [(i: Item) => i.name, (i: Item) => i.customerName];
|
||||
|
||||
it('ignoriert Gross-/Kleinschreibung und durchsucht alle Felder', () => {
|
||||
expect(matchesText(items[1], 'ZELLER', fields)).toBe(true);
|
||||
expect(matchesText(items[1], 'b.de', fields)).toBe(true);
|
||||
expect(matchesText(items[1], 'xyz', fields)).toBe(false);
|
||||
});
|
||||
|
||||
it('leere Suche trifft alles, null-Felder stoeren nicht', () => {
|
||||
expect(matchesText(items[0], ' ', fields)).toBe(true);
|
||||
expect(matchesText(items[0], 'zeller', fields)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Filtern, Suchen und Gruppieren nach Kunde (quick-261008-dts). Reine
|
||||
* Hilfsfunktionen ohne React: Kontakte und Domains tragen beide
|
||||
* `customerId`/`customerName`. Die Eingabelisten werden nie veraendert.
|
||||
*/
|
||||
|
||||
/** Schluessel fuer "keinem Kunden zugeordnet" (Filterwert und Gruppenschluessel). */
|
||||
export const UNASSIGNED = 'unassigned';
|
||||
/** Filterwert "alle Kunden". */
|
||||
export const ALL_CUSTOMERS = 'all';
|
||||
|
||||
export interface HasCustomer {
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
}
|
||||
|
||||
export interface CustomerRef {
|
||||
id: string;
|
||||
name: string;
|
||||
isOwnCompany: boolean;
|
||||
}
|
||||
|
||||
export interface CustomerGroup<T> {
|
||||
/** Kunden-Id oder `UNASSIGNED`. */
|
||||
key: string;
|
||||
/** Anzeigename; `null` bei der Gruppe "Nicht zugeordnet" (Beschriftung kommt aus den Texten). */
|
||||
name: string | null;
|
||||
isOwnCompany: boolean;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
/** `'all'`, `'unassigned'` oder eine Kunden-Id. */
|
||||
export function filterByCustomer<T extends HasCustomer>(items: T[], filter: string): T[] {
|
||||
if (filter === ALL_CUSTOMERS) return [...items];
|
||||
if (filter === UNASSIGNED) return items.filter((item) => item.customerId === null);
|
||||
return items.filter((item) => item.customerId === filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppen nach Kunde: eigene Firma zuerst, dann nach Name (deutsche
|
||||
* Sortierung), "Nicht zugeordnet" zuletzt. Leere Gruppen entfallen.
|
||||
*/
|
||||
export function groupByCustomer<T extends HasCustomer>(
|
||||
items: T[],
|
||||
customers: CustomerRef[],
|
||||
): CustomerGroup<T>[] {
|
||||
const known = new Map(customers.map((c) => [c.id, c]));
|
||||
const groups = new Map<string, CustomerGroup<T>>();
|
||||
for (const item of items) {
|
||||
const key = item.customerId ?? UNASSIGNED;
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
const customer = item.customerId ? known.get(item.customerId) : undefined;
|
||||
group = {
|
||||
key,
|
||||
name: item.customerId ? (customer?.name ?? item.customerName ?? item.customerId) : null,
|
||||
isOwnCompany: customer?.isOwnCompany ?? false,
|
||||
items: [],
|
||||
};
|
||||
groups.set(key, group);
|
||||
}
|
||||
group.items.push(item);
|
||||
}
|
||||
const collator = new Intl.Collator('de', { sensitivity: 'base', numeric: true });
|
||||
return [...groups.values()].sort((a, b) => {
|
||||
if (a.key === UNASSIGNED) return 1;
|
||||
if (b.key === UNASSIGNED) return -1;
|
||||
if (a.isOwnCompany !== b.isOwnCompany) return a.isOwnCompany ? -1 : 1;
|
||||
return collator.compare(a.name ?? '', b.name ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
/** Gross-/Kleinschreibung egal; leere Suche trifft alles. */
|
||||
export function matchesText<T>(
|
||||
item: T,
|
||||
query: string,
|
||||
fields: ((item: T) => string | null | undefined)[],
|
||||
): boolean {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return true;
|
||||
return fields.some((field) => (field(item) ?? '').toLowerCase().includes(needle));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Gemeinsame Klassen der Domains-Seite (Muster `SettingsTab.tsx`). */
|
||||
export const INPUT_CLASS =
|
||||
'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
|
||||
export const PRIMARY_BUTTON =
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50';
|
||||
export const SECONDARY_BUTTON =
|
||||
'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-accent transition-colors disabled:cursor-not-allowed disabled:opacity-50';
|
||||
export const TABLE_HEAD_CLASS = 'bg-muted/50 text-xs text-muted-foreground';
|
||||
export const TABLE_HEAD_CELL = 'px-3 py-2 font-medium';
|
||||
export const TABLE_CELL = 'px-3 py-2';
|
||||
Reference in New Issue
Block a user