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:
2026-10-08 10:50:06 +02:00
parent 1f1c984184
commit 450218fe77
26 changed files with 3943 additions and 36 deletions
@@ -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';