Zum Inhalt springen
UI Kit
Accent

CRM Kontaktansicht

Ein CRM-Kontakt in fünf Screens. Profil mit Reitern, Verlauf mit Filter und neuen Einträgen, Deals als Stufenleiter, Notizen mit Erwähnungen und Liste mit Detail.

5 VariantsEine von 2 Components in CRM und Rechnungen

Ein Kunde, alles in Reichweite

Die Kontaktansicht ist der meistgenutzte Teil eines CRM, direkt vor einem Anruf oder nach einem Termin. Diese fünf Screens legen Person, bisheriges Gespräch, offene Deals und Notizen nur einen Reiter auseinander, Mail, Anruf und Termin sind echte Aktionen.

Das „Kontaktprofil“ zeigt Avatar, Firma, die wichtigsten Felder, drei Kennzahlen und Reiter für Verlauf, Deals und Notizen. Der „Kontaktverlauf“ legt Mails, Anrufe, Termine und Notizen nach Tagen auf eine Linie, mit Filtern und Zahlen, einem Feld für neue Einträge und dem Kontakt in Zahlen. „Deals des Kontakts“ führt jeden Deal mit Wert, Chance und Abschluss, die Phase als Leiter zum Ändern, Summen für Pipeline, gewichtet und gewonnen und ein Formular für neue Deals. „Kontaktnotizen“ setzt angeheftete Notizen nach vorn, schreibt mit @-Erwähnungen, bearbeitet direkt und löscht mit Rückgängig. Die „Kontaktliste mit Detail“ lässt die Pfeiltasten durch eine gefilterte Liste wandern, das Detail zeigt Felder, offene Deals und letzte Aktivität.

Stell den nächsten Schritt für diesen Kontakt nach vorn, dann den Verlauf. Beschränk die wichtigsten Felder auf die, die dein Team wirklich pflegt, fünf gute wirken mehr als zwanzig leere. Eine gewichtete Pipeline braucht ehrliche Chancen, lass die Chance deshalb direkt neben der Phase setzen.

Variants

KontaktprofilNeu in 1.2

Avatar, Firma, Mail, Anruf und Termin als echte Aktionen, die wichtigsten Felder, drei Kennzahlen und Reiter für Verlauf, Deals und Notizen.

KontaktverlaufNeu in 1.2

Mails, Anrufe, Termine und Notizen nach Tagen auf einer Linie, Filter mit Zahlen, ein Feld für neue Einträge und der Kontakt in Zahlen.

Deals des KontaktsNeu in 1.2

Jeder Deal mit Wert, Chance und Abschluss, die Phase als Leiter zum Ändern, Summen für Pipeline, gewichtet und gewonnen und ein Formular für neue Deals.

KontaktnotizenNeu in 1.2

Angeheftete Notizen zuerst, schreiben mit @-Erwähnungen aus einer Liste, direkt bearbeiten, löschen mit Rückgängig und nach Erwähnten filtern.

Kontaktliste mit DetailNeu in 1.2

Liste mit Suche und Filter, Pfeiltasten wandern durch, das Detail zeigt Felder, offene Deals und letzte Aktivität. Am Telefon im Wechsel.

Details

Was CRM-Kontakt mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/crm-contact.tsx
Bringt mit
utilskitavatarbuttoniconmotionsidebar
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 107 eigene Texte
Studio
Im Code anpassen
In Sammlungen
SaaS-App-Screens

Props

Die Schnittstelle CrmContactProps aus components/ui/crm-contact.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    CrmContactVariant

    profiletimelinedealsnoteslist-detail

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titleoptional
    string
  • subtitleoptional
    string
  • nowoptional
    string

    ISO date and time that counts as now

  • currencyoptional
    string

    ISO currency code, e.g. EUR or USD

  • contactoptional
    CrmPerson

    The person on screen (all variants but list-detail)

  • contactsoptional
    CrmPerson[]

    list-detail: everybody in the list

  • activitiesoptional
    CrmActivity[]
  • dealsoptional
    CrmDeal[]
  • stagesoptional
    CrmStage[]

    The pipeline in order. The last stage counts as won.

  • notesoptional
    CrmNote[]
  • meoptional
    string

    Name of the person using the app, author of new entries

  • peopleoptional
    string[]

    People you can mention in notes with @

  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on add, edit, stage change, pin and delete. Without it the block simulates the request.

Eingebaute Texte

Diese Texte schreibt CRM-Kontakt selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 107 Texte anzeigen
emailEnglisch EmailDeutsch E-Mail
callEnglisch CallDeutsch Anruf
meetingEnglisch MeetingDeutsch Termin
noteEnglisch NoteDeutsch Notiz
emailsEnglisch EmailsDeutsch E-Mails
callsEnglisch CallsDeutsch Anrufe
meetingsEnglisch MeetingsDeutsch Termine
notesTabEnglisch NotesDeutsch Notizen
allEnglisch AllDeutsch Alle
historyEnglisch HistoryDeutsch Verlauf
dealsEnglisch DealsDeutsch Deals
detailsEnglisch DetailsDeutsch Details
showDetailsEnglisch Show detailsDeutsch Details zeigen
hideDetailsEnglisch Hide detailsDeutsch Details ausblenden
sendEmailEnglisch Write emailDeutsch E-Mail schreiben
callNowEnglisch CallDeutsch Anrufen
scheduleEnglisch Book meetingDeutsch Termin buchen
scheduledEnglisch Meeting request sent to {name}.Deutsch Terminanfrage an {name} gesendet.
leadEnglisch LeadDeutsch Lead
customerEnglisch CustomerDeutsch Kunde
inactiveEnglisch InactiveDeutsch Inaktiv
ownerEnglisch OwnerDeutsch Betreut von
locationEnglisch LocationDeutsch Ort
phoneEnglisch PhoneDeutsch Telefon
mailEnglisch EmailDeutsch E-Mail
revenueEnglisch Revenue so farDeutsch Umsatz bisher
openPipelineEnglisch Open dealsDeutsch Offene Deals
lastContactEnglisch Last contactDeutsch Letzter Kontakt
nextMeetingEnglisch Next meetingDeutsch Nächster Termin
noneEnglisch None plannedDeutsch Noch keiner geplant
contactSheetEnglisch ContactDeutsch Kontakt
tabsEnglisch Contact sectionsDeutsch Bereiche des Kontakts
composerEnglisch New entryDeutsch Neuer Eintrag
entryTypeEnglisch Type of entryDeutsch Art des Eintrags
placeholderNoteEnglisch What did you agree on?Deutsch Was habt ihr vereinbart?
placeholderCallEnglisch What did you talk about?Deutsch Worüber habt ihr gesprochen?
placeholderEmailEnglisch Subject and short summary of the emailDeutsch Betreff und kurze Zusammenfassung der E-Mail
placeholderMeetingEnglisch Topic and outcome of the meetingDeutsch Thema und Ergebnis des Termins
addEnglisch Add entryDeutsch Eintrag hinzufügen
addingEnglisch Adding…Deutsch Wird hinzugefügt…
addedEnglisch Entry added to the history.Deutsch Eintrag im Verlauf gespeichert.
shortcutEnglisch Ctrl and Enter adds the entryDeutsch Strg und Enter fügt den Eintrag hinzu
filterEnglisch ShowDeutsch Zeigen
todayEnglisch TodayDeutsch Heute
yesterdayEnglisch YesterdayDeutsch Gestern
minutesEnglisch {n} minDeutsch {n} Min.
moreEnglisch Read moreDeutsch Weiterlesen
lessEnglisch Show lessDeutsch Weniger zeigen
emptyEnglisch Nothing here yet. Your first entry starts the history.Deutsch Hier steht noch nichts. Dein erster Eintrag beginnt den Verlauf.
inNumbersEnglisch Contact in numbersDeutsch Kontakt in Zahlen
pipelineEnglisch PipelineDeutsch Pipeline
weightedEnglisch WeightedDeutsch Gewichtet
wonEnglisch WonDeutsch Gewonnen
winRateEnglisch Win rateDeutsch Abschlussquote
stageEnglisch Stage of {name}Deutsch Phase von {name}
stageNowEnglisch {name} is now in {stage}.Deutsch {name} ist jetzt in {stage}.
nextStageEnglisch Next stageDeutsch Nächste Phase
prevStageEnglisch Previous stageDeutsch Vorige Phase
chanceEnglisch Chance {p}Deutsch Chance {p}
closesEnglisch Closes {date}Deutsch Abschluss {date}
markLostEnglisch Mark as lostDeutsch Als verloren markieren
reopenEnglisch Reopen dealDeutsch Deal wieder öffnen
lostNowEnglisch {name} is marked as lost.Deutsch {name} ist als verloren markiert.
reopenedEnglisch {name} is open again.Deutsch {name} ist wieder offen.
lostTagEnglisch LostDeutsch Verloren
wonTagEnglisch WonDeutsch Gewonnen
newDealEnglisch New dealDeutsch Neuer Deal
dealNameEnglisch Deal nameDeutsch Name des Deals
dealValueEnglisch ValueDeutsch Wert
dealStageEnglisch StageDeutsch Phase
createDealEnglisch Create dealDeutsch Deal anlegen
dealCreatedEnglisch Deal {name} created.Deutsch Deal {name} angelegt.
nameNeededEnglisch Give the deal a name.Deutsch Gib dem Deal einen Namen.
valueNeededEnglisch Enter a value above zero.Deutsch Trag einen Wert über null ein.
noDealsEnglisch No deals yet. Create the first one below.Deutsch Noch keine Deals. Leg unten den ersten an.
notesEnglisch NotesDeutsch Notizen
pinnedEnglisch PinnedDeutsch Angeheftet
othersEnglisch More notesDeutsch Weitere Notizen
writeNoteEnglisch Write a note, type @ to mention someoneDeutsch Notiz schreiben, mit @ jemanden erwähnen
saveNoteEnglisch Save noteDeutsch Notiz speichern
noteSavedEnglisch Note saved.Deutsch Notiz gespeichert.
pinEnglisch Pin noteDeutsch Notiz anheften
unpinEnglisch Unpin noteDeutsch Notiz lösen
editEnglisch Edit noteDeutsch Notiz bearbeiten
delEnglisch Delete noteDeutsch Notiz löschen
deletedEnglisch Note deleted.Deutsch Notiz gelöscht.
undoEnglisch UndoDeutsch Rückgängig
restoredEnglisch Note restored.Deutsch Notiz wiederhergestellt.
saveEnglisch Save changesDeutsch Änderungen speichern
cancelEnglisch CancelDeutsch Abbrechen
editedEnglisch Note updated.Deutsch Notiz aktualisiert.
mentionsEnglisch MentionsDeutsch Erwähnungen
mentionsOfEnglisch Notes mentioning {name}Deutsch Notizen, die {name} erwähnen
allNotesEnglisch All notesDeutsch Alle Notizen
suggestionsEnglisch People to mentionDeutsch Personen zum Erwähnen
noNotesEnglisch No note matches. Write the first one above.Deutsch Keine Notiz passt. Schreib oben die erste.
searchNotesEnglisch Search notesDeutsch Notizen durchsuchen
contactsEnglisch ContactsDeutsch Kontakte
searchEnglisch Search contactsDeutsch Kontakte suchen
noMatchEnglisch No contact matches. Try a company or a city.Deutsch Kein Kontakt passt. Versuch es mit Firma oder Ort.
backEnglisch Back to contactsDeutsch Zurück zu den Kontakten
recentEnglisch Recent activityDeutsch Letzte Aktivität
openDealsEnglisch Open dealsDeutsch Offene Deals
countEnglisch {n} contactsDeutsch {n} Kontakte
nDealsEnglisch {n} openDeutsch {n} offen
agoEnglisch Last contact {when}Deutsch Letzter Kontakt {when}
noContactEnglisch Pick a contact to see the details.Deutsch Wähle einen Kontakt, um die Details zu sehen.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • CRM-Kontakt reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende, Tab und Leertaste.
  • Setzt aria-activedescendant, aria-autocomplete, aria-checked, aria-controls, aria-describedby, aria-disabled, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen combobox, group, listbox, option, radio, radiogroup, status, tab, tablist und tabpanel.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.776 Zeilen
components/ui/crm-contact.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { useScrollFade } from '@/components/ui/motion';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';

export type CrmContactVariant = 'profile' | 'timeline' | 'deals' | 'notes' | 'list-detail';

export type CrmActivityType = 'email' | 'call' | 'meeting' | 'note';

export interface CrmField {
  label: string;
  value: string;
  /** mailto:, tel: or a web address */
  href?: string;
}

export interface CrmPerson {
  id: string;
  name: string;
  role?: string;
  company?: string;
  email?: string;
  phone?: string;
  location?: string;
  /** Who looks after the contact */
  owner?: string;
  status?: 'lead' | 'customer' | 'inactive';
  tags?: string[];
  /** More fields, e.g. industry, website, customer number */
  fields?: CrmField[];
  /** Revenue so far, in the currency of the block */
  revenue?: number;
  /** ISO date and time of the last contact */
  lastContact?: string;
  /** ISO date and time of the next appointment */
  nextMeeting?: string;
}

export interface CrmActivity {
  id: string;
  type: CrmActivityType;
  title: string;
  body?: string;
  /** ISO date and time */
  time: string;
  author?: string;
  /** Calls and meetings, in minutes */
  minutes?: number;
  /** list-detail: the contact it belongs to */
  contactId?: string;
}

export interface CrmStage {
  id: string;
  label: string;
  /** Default chance to win in this stage, 0 to 100 */
  probability: number;
}

export interface CrmDeal {
  id: string;
  name: string;
  value: number;
  /** Stage id */
  stage: string;
  /** 0 to 100, defaults to the stage */
  probability?: number;
  /** ISO date of the expected close */
  close?: string;
  owner?: string;
  lost?: boolean;
  contactId?: string;
}

export interface CrmNote {
  id: string;
  /** Plain text, mentions written as @Name */
  body: string;
  author: string;
  /** ISO date and time */
  time: string;
  pinned?: boolean;
  contactId?: string;
}

export interface CrmContactProps {
  variant?: CrmContactVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title?: string;
  subtitle?: string;
  /** ISO date and time that counts as now */
  now?: string;
  /** ISO currency code, e.g. EUR or USD */
  currency?: string;
  /** The person on screen (all variants but list-detail) */
  contact?: CrmPerson;
  /** list-detail: everybody in the list */
  contacts?: CrmPerson[];
  activities?: CrmActivity[];
  deals?: CrmDeal[];
  /** The pipeline in order. The last stage counts as won. */
  stages?: CrmStage[];
  notes?: CrmNote[];
  /** Name of the person using the app, author of new entries */
  me?: string;
  /** People you can mention in notes with @ */
  people?: string[];
  /** Called on add, edit, stage change, pin and delete. Without it the block simulates the request. */
  onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    email: 'Email',
    call: 'Call',
    meeting: 'Meeting',
    note: 'Note',
    emails: 'Emails',
    calls: 'Calls',
    meetings: 'Meetings',
    notesTab: 'Notes',
    all: 'All',
    history: 'History',
    deals: 'Deals',
    details: 'Details',
    showDetails: 'Show details',
    hideDetails: 'Hide details',
    sendEmail: 'Write email',
    callNow: 'Call',
    schedule: 'Book meeting',
    scheduled: 'Meeting request sent to {name}.',
    lead: 'Lead',
    customer: 'Customer',
    inactive: 'Inactive',
    owner: 'Owner',
    location: 'Location',
    phone: 'Phone',
    mail: 'Email',
    revenue: 'Revenue so far',
    openPipeline: 'Open deals',
    lastContact: 'Last contact',
    nextMeeting: 'Next meeting',
    none: 'None planned',
    contactSheet: 'Contact',
    tabs: 'Contact sections',
    composer: 'New entry',
    entryType: 'Type of entry',
    placeholderNote: 'What did you agree on?',
    placeholderCall: 'What did you talk about?',
    placeholderEmail: 'Subject and short summary of the email',
    placeholderMeeting: 'Topic and outcome of the meeting',
    add: 'Add entry',
    adding: 'Adding…',
    added: 'Entry added to the history.',
    shortcut: 'Ctrl and Enter adds the entry',
    filter: 'Show',
    today: 'Today',
    yesterday: 'Yesterday',
    minutes: '{n} min',
    more: 'Read more',
    less: 'Show less',
    empty: 'Nothing here yet. Your first entry starts the history.',
    inNumbers: 'Contact in numbers',
    pipeline: 'Pipeline',
    weighted: 'Weighted',
    won: 'Won',
    winRate: 'Win rate',
    stage: 'Stage of {name}',
    stageNow: '{name} is now in {stage}.',
    nextStage: 'Next stage',
    prevStage: 'Previous stage',
    chance: 'Chance {p}',
    closes: 'Closes {date}',
    markLost: 'Mark as lost',
    reopen: 'Reopen deal',
    lostNow: '{name} is marked as lost.',
    reopened: '{name} is open again.',
    lostTag: 'Lost',
    wonTag: 'Won',
    newDeal: 'New deal',
    dealName: 'Deal name',
    dealValue: 'Value',
    dealStage: 'Stage',
    createDeal: 'Create deal',
    dealCreated: 'Deal {name} created.',
    nameNeeded: 'Give the deal a name.',
    valueNeeded: 'Enter a value above zero.',
    noDeals: 'No deals yet. Create the first one below.',
    notes: 'Notes',
    pinned: 'Pinned',
    others: 'More notes',
    writeNote: 'Write a note, type @ to mention someone',
    saveNote: 'Save note',
    noteSaved: 'Note saved.',
    pin: 'Pin note',
    unpin: 'Unpin note',
    edit: 'Edit note',
    del: 'Delete note',
    deleted: 'Note deleted.',
    undo: 'Undo',
    restored: 'Note restored.',
    save: 'Save changes',
    cancel: 'Cancel',
    edited: 'Note updated.',
    mentions: 'Mentions',
    mentionsOf: 'Notes mentioning {name}',
    allNotes: 'All notes',
    suggestions: 'People to mention',
    noNotes: 'No note matches. Write the first one above.',
    searchNotes: 'Search notes',
    contacts: 'Contacts',
    search: 'Search contacts',
    noMatch: 'No contact matches. Try a company or a city.',
    back: 'Back to contacts',
    recent: 'Recent activity',
    openDeals: 'Open deals',
    count: '{n} contacts',
    nDeals: '{n} open',
    ago: 'Last contact {when}',
    noContact: 'Pick a contact to see the details.',
  },
  de: {
    email: 'E-Mail',
    call: 'Anruf',
    meeting: 'Termin',
    note: 'Notiz',
    emails: 'E-Mails',
    calls: 'Anrufe',
    meetings: 'Termine',
    notesTab: 'Notizen',
    all: 'Alle',
    history: 'Verlauf',
    deals: 'Deals',
    details: 'Details',
    showDetails: 'Details zeigen',
    hideDetails: 'Details ausblenden',
    sendEmail: 'E-Mail schreiben',
    callNow: 'Anrufen',
    schedule: 'Termin buchen',
    scheduled: 'Terminanfrage an {name} gesendet.',
    lead: 'Lead',
    customer: 'Kunde',
    inactive: 'Inaktiv',
    owner: 'Betreut von',
    location: 'Ort',
    phone: 'Telefon',
    mail: 'E-Mail',
    revenue: 'Umsatz bisher',
    openPipeline: 'Offene Deals',
    lastContact: 'Letzter Kontakt',
    nextMeeting: 'Nächster Termin',
    none: 'Noch keiner geplant',
    contactSheet: 'Kontakt',
    tabs: 'Bereiche des Kontakts',
    composer: 'Neuer Eintrag',
    entryType: 'Art des Eintrags',
    placeholderNote: 'Was habt ihr vereinbart?',
    placeholderCall: 'Worüber habt ihr gesprochen?',
    placeholderEmail: 'Betreff und kurze Zusammenfassung der E-Mail',
    placeholderMeeting: 'Thema und Ergebnis des Termins',
    add: 'Eintrag hinzufügen',
    adding: 'Wird hinzugefügt…',
    added: 'Eintrag im Verlauf gespeichert.',
    shortcut: 'Strg und Enter fügt den Eintrag hinzu',
    filter: 'Zeigen',
    today: 'Heute',
    yesterday: 'Gestern',
    minutes: '{n} Min.',
    more: 'Weiterlesen',
    less: 'Weniger zeigen',
    empty: 'Hier steht noch nichts. Dein erster Eintrag beginnt den Verlauf.',
    inNumbers: 'Kontakt in Zahlen',
    pipeline: 'Pipeline',
    weighted: 'Gewichtet',
    won: 'Gewonnen',
    winRate: 'Abschlussquote',
    stage: 'Phase von {name}',
    stageNow: '{name} ist jetzt in {stage}.',
    nextStage: 'Nächste Phase',
    prevStage: 'Vorige Phase',
    chance: 'Chance {p}',
    closes: 'Abschluss {date}',
    markLost: 'Als verloren markieren',
    reopen: 'Deal wieder öffnen',
    lostNow: '{name} ist als verloren markiert.',
    reopened: '{name} ist wieder offen.',
    lostTag: 'Verloren',
    wonTag: 'Gewonnen',
    newDeal: 'Neuer Deal',
    dealName: 'Name des Deals',
    dealValue: 'Wert',
    dealStage: 'Phase',
    createDeal: 'Deal anlegen',
    dealCreated: 'Deal {name} angelegt.',
    nameNeeded: 'Gib dem Deal einen Namen.',
    valueNeeded: 'Trag einen Wert über null ein.',
    noDeals: 'Noch keine Deals. Leg unten den ersten an.',
    notes: 'Notizen',
    pinned: 'Angeheftet',
    others: 'Weitere Notizen',
    writeNote: 'Notiz schreiben, mit @ jemanden erwähnen',
    saveNote: 'Notiz speichern',
    noteSaved: 'Notiz gespeichert.',
    pin: 'Notiz anheften',
    unpin: 'Notiz lösen',
    edit: 'Notiz bearbeiten',
    del: 'Notiz löschen',
    deleted: 'Notiz gelöscht.',
    undo: 'Rückgängig',
    restored: 'Notiz wiederhergestellt.',
    save: 'Änderungen speichern',
    cancel: 'Abbrechen',
    edited: 'Notiz aktualisiert.',
    mentions: 'Erwähnungen',
    mentionsOf: 'Notizen, die {name} erwähnen',
    allNotes: 'Alle Notizen',
    suggestions: 'Personen zum Erwähnen',
    noNotes: 'Keine Notiz passt. Schreib oben die erste.',
    searchNotes: 'Notizen durchsuchen',
    contacts: 'Kontakte',
    search: 'Kontakte suchen',
    noMatch: 'Kein Kontakt passt. Versuch es mit Firma oder Ort.',
    back: 'Zurück zu den Kontakten',
    recent: 'Letzte Aktivität',
    openDeals: 'Offene Deals',
    count: '{n} Kontakte',
    nDeals: '{n} offen',
    ago: 'Letzter Kontakt {when}',
    noContact: 'Wähle einen Kontakt, um die Details zu sehen.',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/* ------------------------------------------------------------------ */
/* Motion                                                              */
/* ------------------------------------------------------------------ */
/*
 * An area waits until it scrolls in (data-crmc=wait), then plays. Children
 * take crmc-up, crmc-pop or crmc-grow with --crmc-d as delay. crmc-in, crmc-fly
 * and crmc-flash run once when something new appears. The server renders the
 * end state, without JavaScript nothing waits longer than 2.5 s, with reduced
 * motion nothing moves.
 */
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const FX: [string, string, number][] = [
  ['up', 'opacity:0;transform:translateY(10px)', 600],
  ['pop', 'opacity:0;transform:scale(.94)', 450],
  ['grow', 'transform:scaleX(0)', 900],
];
const CSS =
  FX.map(([k, v]) => `@keyframes crmc-${k}{from{${v}}}@keyframes crmc-${k}-h{from{${v}}}`).join('') +
  '@keyframes crmc-fly{from{opacity:0;transform:translateX(16px)}}@keyframes crmc-flash{from{background-color:color-mix(in oklab,var(--primary) 12%,transparent)}}.crmc-grow{transform-origin:left}' +
  '@media (prefers-reduced-motion:no-preference){' +
  FX.map(([k, , d]) => `[data-crmc=play] .crmc-${k}{animation:crmc-${k} ${d}ms var(--ease-kit) var(--crmc-d,0ms) both}[data-crmc=wait] .crmc-${k}{animation:crmc-${k}-h 0s 1s both paused}`).join('') +
  '.crmc-in{animation:crmc-up .45s var(--ease-kit) var(--crmc-d,0ms) both}.crmc-fly{animation:crmc-fly .45s var(--ease-kit) var(--crmc-d,0ms) both}.crmc-flash{animation:crmc-flash 1.8s ease-out both}' +
  '}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
  FX.map(([k]) => `[data-crmc-scope]:not([data-crmc]) .crmc-${k}{animation:crmc-${k}-h 0s 2.5s both}`).join('') +
  '}';

function Style() {
  return (
    <style href="kitb5-crm-contact" precedence="default">
      {CSS}
    </style>
  );
}

function useEnter<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
    setState('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        setState('play');
        io.disconnect();
      },
      { rootMargin: '0px 0px -8% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, { 'data-crmc-scope': '', 'data-crmc': state }] as const;
}

type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
  const r = el.getBoundingClientRect();
  const b = root.getBoundingClientRect();
  return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};

/** The marker [data-glide] inside the active item glides over from the one before */
function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<Box | null>(null);
  if (typeof window !== 'undefined' && ref.current) {
    const m = ref.current.querySelector('[data-glide]');
    before.current = m ? boxOf(m, ref.current) : null;
  }
  useIso(() => {
    const root = ref.current;
    const m = root?.querySelector<HTMLElement>('[data-glide]');
    const a = before.current;
    if (!root || !m || !a || prefersReducedMotion()) return;
    const b = boxOf(m, root);
    if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
    m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: EASE });
  }, [key]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* Helpers                                                             */
/* ------------------------------------------------------------------ */

const MIN = 60000;
const toMs = (iso: string) => Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z'));
const tagOf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: CrmContactProps['onAction'], action: string, data: Record<string, unknown>, ms = 600) {
  if (onAction) await onAction(action, data);
  else await wait(prefersReducedMotion() ? 0 : ms);
}

type Fmt = ReturnType<typeof makeFmt>;
function makeFmt(locale: Locale, currency: string, now: string) {
  const tag = tagOf(locale);
  const money0 = new Intl.NumberFormat(tag, { style: 'currency', currency, maximumFractionDigits: 0 });
  const num = new Intl.NumberFormat(tag);
  const pct = (n: number) => (locale === 'de' ? `${num.format(Math.round(n))} %` : `${num.format(Math.round(n))}%`);
  const date = (iso: string, o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(tag, { timeZone: 'UTC', ...o }).format(new Date(toMs(iso)));
  const time = (iso: string) => date(iso, { hour: locale === 'de' ? '2-digit' : 'numeric', minute: '2-digit' });
  const rtf = new Intl.RelativeTimeFormat(tag, { numeric: 'auto' });
  const ago = (iso: string) => {
    const d = Math.round((toMs(iso) - toMs(now)) / MIN);
    const a = Math.abs(d);
    if (a < 60) return rtf.format(d, 'minute');
    if (a < 1440) return rtf.format(Math.round(d / 60), 'hour');
    if (a < 1440 * 45) return rtf.format(Math.round(d / 1440), 'day');
    return rtf.format(Math.round(d / 43200), 'month');
  };
  const dayKey = (iso: string) => Math.floor(toMs(iso) / 86400000);
  return { money: (n: number) => money0.format(n), num: (n: number) => num.format(n), pct, date, time, ago, dayKey };
}

type P = Omit<CrmContactProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; now: string; locale: Locale; f: Fmt; me: string };

const TYPE: Record<CrmActivityType, { icon: IconName; tone: string }> = {
  email: { icon: 'mail', tone: 'bg-[color-mix(in_oklab,var(--primary)_13%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' },
  call: { icon: 'phone', tone: 'bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] text-success-foreground' },
  meeting: { icon: 'calendar', tone: 'bg-[color-mix(in_oklab,var(--warning)_20%,var(--card))] text-warning-foreground' },
  note: { icon: 'edit', tone: 'bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] text-muted-foreground' },
};

const focusRing = 'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const field =
  'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground hover:border-border-strong focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-destructive sm:h-9 sm:text-[13px]';
const area =
  'block w-full min-w-0 resize-none rounded-lg border border-input bg-background px-3 py-2.5 text-base leading-relaxed text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground hover:border-border-strong focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:text-[13.5px]';
const iconBtn = cn('grid size-11 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:size-9', focusRing);

function Header({ title, subtitle, children }: { title?: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
  if (!title && !subtitle && !children) return null;
  return (
    <div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
        {title && <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>}
      </div>
      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
    </div>
  );
}

function Main({ children, className }: { children: React.ReactNode; className?: string }) {
  return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}

const cardCls = 'rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

/** Chips as a radiogroup with arrow keys, scrolls sideways on phones */
function Chips<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: string; count?: number; icon?: IconName }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
  const [row, fade] = useScrollFade<HTMLDivElement>();
  const onKey = (e: React.KeyboardEvent, i: number) => {
    const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
    const to = e.key === 'Home' ? 0 : e.key === 'End' ? options.length - 1 : dir ? (i + dir + options.length) % options.length : -1;
    if (to < 0) return;
    e.preventDefault();
    onChange(options[to].value);
    const el = row.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[to];
    el?.focus();
    el?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
  };
  return (
    <div ref={row} role="radiogroup" aria-label={label} className={cn('kit-no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 py-1 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0', fade, className)}>
      {options.map((o, i) => {
        const on = o.value === value;
        return (
          <button
            key={o.value}
            type="button"
            role="radio"
            aria-checked={on}
            tabIndex={on ? 0 : -1}
            onClick={() => onChange(o.value)}
            onKeyDown={(e) => onKey(e, i)}
            className={cn(
              'inline-flex h-11 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg border px-3.5 text-[13px] font-medium transition-[background-color,border-color,color] duration-200 sm:h-9',
              focusRing,
              on ? 'border-transparent bg-foreground text-background' : 'border-border bg-card text-muted-foreground hover:border-border-strong hover:text-foreground',
            )}
          >
            {o.icon && <Icon name={o.icon} size={14} />}
            {o.label}
            {o.count !== undefined && <span className={cn('text-[12px] tabular-nums', on ? 'text-[color-mix(in_oklab,var(--background)_70%,transparent)]' : 'text-subtle-foreground')}>{o.count}</span>}
          </button>
        );
      })}
    </div>
  );
}

/** Tabs after the APG pattern: arrow keys, Home and End, automatic activation */
function Tabs<T extends string>({ id, tabs, value, onChange, label, className }: { id: string; tabs: { value: T; label: string; count?: number }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
  const ref = useGlide<HTMLDivElement>(value);
  const go = (i: number) => {
    const n = (i + tabs.length) % tabs.length;
    onChange(tabs[n].value);
    document.getElementById(`${id}-tab-${tabs[n].value}`)?.focus();
  };
  return (
    <div ref={ref} role="tablist" aria-label={label} className={cn('relative flex border-b border-border', className)}>
      {tabs.map((x, i) => {
        const on = x.value === value;
        return (
          <button
            key={x.value}
            id={`${id}-tab-${x.value}`}
            type="button"
            role="tab"
            aria-selected={on}
            aria-controls={`${id}-panel-${x.value}`}
            tabIndex={on ? 0 : -1}
            onClick={() => onChange(x.value)}
            onKeyDown={(e) => {
              if (e.key === 'ArrowRight') (e.preventDefault(), go(i + 1));
              if (e.key === 'ArrowLeft') (e.preventDefault(), go(i - 1));
              if (e.key === 'Home') (e.preventDefault(), go(0));
              if (e.key === 'End') (e.preventDefault(), go(tabs.length - 1));
            }}
            className={cn('relative inline-flex h-12 flex-1 cursor-pointer items-center justify-center gap-1.5 px-3 text-[13.5px] font-medium outline-none transition-colors sm:flex-none sm:justify-start sm:px-4', 'focus-visible:bg-[color-mix(in_oklab,var(--primary)_8%,transparent)] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]', on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}
          >
            {x.label}
            {x.count !== undefined && <span className="rounded-full bg-muted px-1.5 text-[11.5px] tabular-nums text-muted-foreground">{x.count}</span>}
            {on && <span data-glide aria-hidden className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-primary" />}
          </button>
        );
      })}
    </div>
  );
}

function StatusBadge({ status, t }: { status?: CrmPerson['status']; t: Texts }) {
  if (!status) return null;
  return (
    <span
      className={cn(
        'inline-flex h-6 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium',
        status === 'customer' ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : status === 'lead' ? 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'bg-muted text-muted-foreground',
      )}
    >
      <span className={cn('size-1.5 rounded-full', status === 'customer' ? 'bg-success' : status === 'lead' ? 'bg-primary' : 'bg-subtle-foreground')} />
      {t[status]}
    </span>
  );
}

/** Mail, call and meeting as three real actions */
function QuickActions({ c, t, onMeeting, className }: { c: CrmPerson; t: Texts; onMeeting: () => void; className?: string }) {
  const item = cn('group flex min-h-11 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-border bg-surface px-2 py-2.5 text-[12.5px] font-medium text-foreground transition-[border-color,background-color,translate] duration-300 hover:-translate-y-0.5 hover:border-border-strong hover:bg-card', focusRing);
  const ic = 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))] transition-transform duration-300 group-hover:scale-110';
  return (
    <div className={cn('grid grid-cols-3 gap-2', className)}>
      <a href={c.email ? `mailto:${c.email}` : undefined} className={item}>
        <Icon name="mail" size={16} className={ic} />
        {t.sendEmail}
      </a>
      <a href={c.phone ? `tel:${c.phone.replace(/[^\d+]/g, '')}` : undefined} className={item}>
        <Icon name="phone" size={16} className={ic} />
        {t.callNow}
      </a>
      <button type="button" onClick={onMeeting} className={item}>
        <Icon name="calendar-check" size={16} className={ic} />
        {t.schedule}
      </button>
    </div>
  );
}

function detailRows(c: CrmPerson, t: Texts, f: Fmt): CrmField[] {
  const rows: CrmField[] = [];
  if (c.email) rows.push({ label: t.mail, value: c.email, href: `mailto:${c.email}` });
  if (c.phone) rows.push({ label: t.phone, value: c.phone, href: `tel:${c.phone.replace(/[^\d+]/g, '')}` });
  if (c.location) rows.push({ label: t.location, value: c.location });
  if (c.owner) rows.push({ label: t.owner, value: c.owner });
  if (c.revenue !== undefined) rows.push({ label: t.revenue, value: f.money(c.revenue) });
  return [...rows, ...(c.fields ?? [])];
}

function Fields({ rows, className }: { rows: CrmField[]; className?: string }) {
  return (
    <dl className={cn('grid gap-0', className)}>
      {rows.map((r) => (
        <div key={r.label} className="grid grid-cols-[minmax(0,7.5rem)_minmax(0,1fr)] items-baseline gap-3 border-b border-border py-2.5 last:border-b-0">
          <dt className="truncate text-[12.5px] text-muted-foreground">{r.label}</dt>
          <dd className={cn('min-w-0 text-[13.5px] text-foreground', r.href ? 'break-all' : 'truncate')}>
            {r.href ? (
              <a href={r.href} className={cn('cursor-pointer rounded-sm underline decoration-border-strong underline-offset-4 transition-colors hover:decoration-primary', focusRing)}>
                {r.value}
              </a>
            ) : (
              r.value
            )}
          </dd>
        </div>
      ))}
    </dl>
  );
}

/** Head of a contact: avatar, name, role at company, status and tags */
function PersonHead({ c, t, size = 'xl', center }: { c: CrmPerson; t: Texts; size?: 'lg' | 'xl'; center?: boolean }) {
  return (
    <div className={cn('flex min-w-0 gap-4', center ? 'flex-col items-center text-center sm:flex-row sm:items-center sm:text-left' : 'items-center')}>
      <Avatar name={c.name} size={size} />
      <div className="min-w-0">
        <h2 className="truncate text-[19px] font-semibold tracking-[-0.02em] text-foreground">{c.name}</h2>
        <p className="mt-0.5 truncate text-[13.5px] text-muted-foreground">{[c.role, c.company].filter(Boolean).join(' · ')}</p>
        <div className={cn('mt-2 flex flex-wrap items-center gap-1.5', center && 'justify-center sm:justify-start')}>
          <StatusBadge status={c.status} t={t} />
          {c.tags?.map((x) => (
            <span key={x} className="inline-flex h-6 items-center rounded-md border border-border px-2 text-[12px] text-muted-foreground">
              {x}
            </span>
          ))}
        </div>
      </div>
    </div>
  );
}

function ActivityRow({ a, t, f, compact, fresh }: { a: CrmActivity; t: Texts; f: Fmt; compact?: boolean; fresh?: boolean }) {
  const [open, setOpen] = useState(false);
  const id = useId();
  const long = !compact && (a.body?.length ?? 0) > 150;
  const ty = TYPE[a.type];
  return (
    <li className={cn('relative grid grid-cols-[2.25rem_minmax(0,1fr)] gap-3 rounded-lg py-3', fresh && 'crmc-in crmc-flash')}>
      <span className={cn('relative z-[1] grid size-9 place-items-center rounded-full ring-4 ring-card', ty.tone)}>
        <Icon name={ty.icon} size={15} />
      </span>
      <div className="min-w-0 pt-0.5">
        <p className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
          <span className="text-[14px] font-medium text-foreground">{a.title}</span>
          <span className="text-[12.5px] tabular-nums text-subtle-foreground">
            {[t[a.type], a.author, f.time(a.time), a.minutes ? fill(t.minutes, { n: a.minutes }) : ''].filter(Boolean).join(' · ')}
          </span>
        </p>
        {a.body && (
          <div id={id} className={cn('mt-1 text-pretty text-[13.5px] leading-relaxed text-muted-foreground', (compact || (long && !open)) && 'line-clamp-2')}>
            {a.body}
          </div>
        )}
        {long && (
          <button type="button" aria-expanded={open} aria-controls={id} onClick={() => setOpen(!open)} className={cn('mt-1 inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-sm text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))] hover:underline sm:min-h-0', focusRing)}>
            {open ? t.less : t.more}
            <Icon name="chevron-down" size={13} className={cn('transition-transform duration-300', open && 'rotate-180')} />
          </button>
        )}
      </div>
    </li>
  );
}

/** Activities grouped by day on one vertical line */
function ActivityList({ items, t, f, now, compact, freshId }: { items: CrmActivity[]; t: Texts; f: Fmt; now: string; compact?: boolean; freshId?: string }) {
  const today = f.dayKey(now);
  const groups = useMemo(() => {
    const out: { key: number; items: CrmActivity[] }[] = [];
    [...items].sort((a, b) => toMs(b.time) - toMs(a.time)).forEach((a) => {
      const k = f.dayKey(a.time);
      const g = out.find((x) => x.key === k);
      if (g) g.items.push(a);
      else out.push({ key: k, items: [a] });
    });
    return out;
  }, [items, f]);
  if (!items.length) return <p className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.empty}</p>;
  return (
    <div className="grid gap-4">
      {groups.map((g) => (
        <section key={g.key} aria-label={g.key === today ? t.today : g.key === today - 1 ? t.yesterday : f.date(g.items[0].time, { weekday: 'long', day: 'numeric', month: 'long' })}>
          <h3 className="py-1 text-[12px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">
            {g.key === today ? t.today : g.key === today - 1 ? t.yesterday : f.date(g.items[0].time, { weekday: 'long', day: 'numeric', month: 'long' })}
          </h3>
          <ol className="relative">
            <span aria-hidden className="absolute bottom-5 start-[17.5px] top-5 w-px bg-border" />
            {g.items.map((a) => (
              <ActivityRow key={a.id} a={a} t={t} f={f} compact={compact} fresh={a.id === freshId} />
            ))}
          </ol>
        </section>
      ))}
    </div>
  );
}

const stageOf = (stages: CrmStage[], id: string) => Math.max(0, stages.findIndex((s) => s.id === id));
const probOf = (d: CrmDeal, stages: CrmStage[]) => (d.lost ? 0 : (d.probability ?? stages.find((s) => s.id === d.stage)?.probability ?? 0));

function DealRow({ d, stages, t, f }: { d: CrmDeal; stages: CrmStage[]; t: Texts; f: Fmt }) {
  const i = stageOf(stages, d.stage);
  const p = probOf(d, stages);
  const won = i === stages.length - 1 && !d.lost;
  return (
    <li className="grid gap-2 rounded-lg border border-border bg-surface p-3.5 transition-[border-color,translate] duration-300 hover:-translate-y-0.5 hover:border-border-strong">
      <div className="flex items-baseline justify-between gap-3">
        <span className="min-w-0 truncate text-[14px] font-medium text-foreground">{d.name}</span>
        <span className="shrink-0 text-[14px] font-semibold tabular-nums text-foreground">{f.money(d.value)}</span>
      </div>
      <div className="flex gap-1" aria-hidden>
        {stages.map((s, k) => (
          <span key={s.id} className={cn('h-1.5 flex-1 rounded-full', d.lost ? 'bg-track' : k <= i ? (won ? 'bg-success' : 'bg-primary') : 'bg-track')} />
        ))}
      </div>
      <p className="flex flex-wrap justify-between gap-x-3 text-[12.5px] text-muted-foreground">
        <span>{d.lost ? t.lostTag : stages[i]?.label}</span>
        <span className="tabular-nums">{[fill(t.chance, { p: f.pct(p) }), d.close ? fill(t.closes, { date: f.date(d.close, { day: 'numeric', month: 'short' }) }) : ''].filter(Boolean).join(' · ')}</span>
      </p>
    </li>
  );
}

function Mentioned({ text, people }: { text: string; people: string[] }) {
  const parts = useMemo(() => {
    if (!people.length) return [text];
    const names = [...people].sort((a, b) => b.length - a.length).map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
    return text.split(new RegExp(`(@(?:${names.join('|')}))`, 'g'));
  }, [text, people]);
  return (
    <>
      {parts.map((p, i) =>
        p.startsWith('@') && people.includes(p.slice(1)) ? (
          <span key={i} className="rounded bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-1 font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
            {p}
          </span>
        ) : (
          <span key={i}>{p}</span>
        ),
      )}
    </>
  );
}

/* ------------------------------------------------------------------ */
/* profile                                                             */
/* ------------------------------------------------------------------ */

function Profile({ t, f, title, subtitle, contact, activities = [], deals = [], stages = [], notes = [], people = [], now, onAction }: P) {
  const id = useId();
  const [tab, setTab] = useState<'history' | 'deals' | 'notes'>('history');
  const [details, setDetails] = useState(false);
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  if (!contact) return null;
  const c = contact;
  const open = deals.filter((d) => !d.lost && stageOf(stages, d.stage) < stages.length - 1);
  const openSum = open.reduce((s, d) => s + d.value, 0);
  const sorted = [...notes].sort((a, b) => Number(!!b.pinned) - Number(!!a.pinned) || toMs(b.time) - toMs(a.time));
  const tiles = [
    { label: t.openPipeline, value: f.money(openSum), note: fill(t.nDeals, { n: open.length }) },
    { label: t.lastContact, value: c.lastContact ? f.ago(c.lastContact) : t.none, note: c.lastContact ? f.date(c.lastContact, { day: 'numeric', month: 'short' }) : '' },
    { label: t.nextMeeting, value: c.nextMeeting ? f.date(c.nextMeeting, { weekday: 'short', day: 'numeric', month: 'short' }) : t.none, note: c.nextMeeting ? f.time(c.nextMeeting) : '' },
  ];
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-[22rem_minmax(0,1fr)] lg:items-start">
        <section aria-label={t.contactSheet} className={cn(cardCls, 'crmc-up p-4 sm:p-5 lg:sticky lg:top-6')}>
          <PersonHead c={c} t={t} center />
          <QuickActions
            c={c}
            t={t}
            className="mt-5"
            onMeeting={async () => {
              await run(onAction, 'schedule', { id: c.id });
              setMsg(fill(t.scheduled, { name: c.name }));
            }}
          />
          {/* Phones and tablets: details fold away under a button. Desktop: always open. */}
          <span aria-hidden className="mt-4 block h-px bg-border lg:hidden" />
          <button type="button" aria-expanded={details} aria-controls={`${id}-details`} onClick={() => setDetails(!details)} className={cn('mt-1 flex h-11 w-full cursor-pointer items-center justify-between rounded-md px-1 text-[13.5px] font-medium text-foreground lg:hidden', focusRing)}>
            {details ? t.hideDetails : t.showDetails}
            <Icon name="chevron-down" size={16} className={cn('text-muted-foreground transition-transform duration-300', details && 'rotate-180')} />
          </button>
          <div id={`${id}-details`} className={cn('mt-2 lg:mt-5 lg:block lg:border-t lg:border-border lg:pt-2', details ? 'block crmc-in' : 'hidden')}>
            <h3 className="sr-only">{t.details}</h3>
            <Fields rows={detailRows(c, t, f)} />
          </div>
        </section>
        <div className="grid min-w-0 gap-5">
          <ul className="grid grid-cols-[minmax(0,1fr)] gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-3">
            {tiles.map((x, i) => (
              <li key={x.label} className="bg-card px-4 py-3.5 sm:px-5 sm:py-4">
                <div className="crmc-up flex items-baseline justify-between gap-3 sm:block" style={{ '--crmc-d': `${80 + i * 70}ms` } as React.CSSProperties}>
                  <p className="text-[12.5px] text-muted-foreground">{x.label}</p>
                  <p className="text-right sm:mt-1 sm:text-left">
                    <span className="text-[17px] font-semibold tracking-[-0.02em] tabular-nums text-foreground sm:text-[20px]">{x.value}</span>
                    {x.note && <span className="ms-2 text-[12.5px] tabular-nums text-subtle-foreground sm:ms-0 sm:block">{x.note}</span>}
                  </p>
                </div>
              </li>
            ))}
          </ul>
          <section className={cn(cardCls, 'crmc-up min-w-0 overflow-hidden [--crmc-d:160ms]')}>
            <Tabs
              id={id}
              label={t.tabs}
              value={tab}
              onChange={setTab}
              className="px-1 sm:px-3"
              tabs={[
                { value: 'history', label: t.history, count: activities.length },
                { value: 'deals', label: t.deals, count: deals.length },
                { value: 'notes', label: t.notesTab, count: notes.length },
              ]}
            />
            <div role="tabpanel" id={`${id}-panel-${tab}`} aria-labelledby={`${id}-tab-${tab}`} tabIndex={0} className={cn('p-4 sm:p-5', 'outline-none focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_50%,transparent)]')}>
              <div key={tab} className="crmc-in">
                {tab === 'history' && <ActivityList items={activities.slice(0, 6)} t={t} f={f} now={now} compact />}
                {tab === 'deals' &&
                  (deals.length ? (
                    <ul className="grid gap-2.5">
                      {deals.map((d) => (
                        <DealRow key={d.id} d={d} stages={stages} t={t} f={f} />
                      ))}
                    </ul>
                  ) : (
                    <p className="py-8 text-center text-[13.5px] text-muted-foreground">{t.noDeals}</p>
                  ))}
                {tab === 'notes' && (
                  <ul className="grid gap-2.5">
                    {sorted.map((n) => (
                      <li key={n.id} className="rounded-lg border border-border bg-surface p-3.5">
                        <p className="flex items-center gap-2 text-[12.5px] text-subtle-foreground">
                          {n.pinned && <Icon name="pin" size={13} className="text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />}
                          <span className="font-medium text-muted-foreground">{n.author}</span>
                          <span className="tabular-nums">{f.ago(n.time)}</span>
                        </p>
                        <p className="mt-1.5 text-pretty text-[13.5px] leading-relaxed text-foreground">
                          <Mentioned text={n.body} people={people} />
                        </p>
                      </li>
                    ))}
                  </ul>
                )}
              </div>
            </div>
          </section>
        </div>
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* timeline                                                            */
/* ------------------------------------------------------------------ */

function Timeline({ t, f, title, subtitle, contact, activities = [], now, me, onAction }: P) {
  const id = useId();
  const [items, setItems] = useState(activities);
  const [filter, setFilter] = useState<'all' | CrmActivityType>('all');
  const [kind, setKind] = useState<CrmActivityType>('note');
  const [text, setText] = useState('');
  const [busy, setBusy] = useState(false);
  const [fresh, setFresh] = useState('');
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const counts = (k: CrmActivityType) => items.filter((a) => a.type === k).length;
  const shown = items.filter((a) => filter === 'all' || a.type === filter);
  const kinds: CrmActivityType[] = ['note', 'call', 'email', 'meeting'];
  const ph = { note: t.placeholderNote, call: t.placeholderCall, email: t.placeholderEmail, meeting: t.placeholderMeeting };
  const add = async () => {
    const body = text.trim();
    if (!body || busy) return;
    setBusy(true);
    const lines = body.split('\n');
    const a: CrmActivity = { id: `a${Date.now()}`, type: kind, title: lines[0].slice(0, 80), body: lines.slice(1).join('\n').trim() || (lines[0].length > 80 ? body : undefined), time: now, author: me };
    await run(onAction, 'add-activity', { activity: a });
    setItems((l) => [a, ...l]);
    setFresh(a.id);
    setText('');
    setBusy(false);
    if (filter !== 'all' && filter !== kind) setFilter('all');
    setMsg(t.added);
  };
  const total = Math.max(1, items.length);
  const stats = kinds.map((k) => ({ k, n: counts(k) }));
  const kindRef = useGlide<HTMLDivElement>(kind);
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start">
        <div className="grid min-w-0 gap-5">
          {/* Composer */}
          <section aria-labelledby={`${id}-c`} className={cn(cardCls, 'crmc-up p-4 sm:p-5')}>
            <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
              <h2 id={`${id}-c`} className="text-[14px] font-semibold text-foreground">
                {t.composer}
              </h2>
              <div
                ref={kindRef}
                role="radiogroup"
                aria-label={t.entryType}
                className="relative grid grid-cols-4 rounded-lg bg-muted p-1 sm:inline-grid"
                onKeyDown={(e) => {
                  const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
                  if (!dir) return;
                  e.preventDefault();
                  const n = kinds[(kinds.indexOf(kind) + dir + kinds.length) % kinds.length];
                  setKind(n);
                  kindRef.current?.querySelector<HTMLButtonElement>(`[data-k=${n}]`)?.focus();
                }}
              >
                {kinds.map((k) => (
                  <button key={k} data-k={k} type="button" role="radio" title={t[k]} aria-checked={kind === k} tabIndex={kind === k ? 0 : -1} onClick={() => setKind(k)} className={cn('relative inline-flex h-11 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2.5 text-[13px] font-medium transition-colors sm:h-8', focusRing, kind === k ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
                    {kind === k && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
                    <Icon name={TYPE[k].icon} size={14} className="relative" />
                    <span className="relative max-sm:sr-only lg:max-xl:sr-only">{t[k]}</span>
                  </button>
                ))}
              </div>
            </div>
            <label htmlFor={`${id}-text`} className="sr-only">
              {ph[kind]}
            </label>
            <textarea
              id={`${id}-text`}
              rows={3}
              value={text}
              placeholder={ph[kind]}
              onChange={(e) => setText(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) (e.preventDefault(), void add());
              }}
              aria-describedby={`${id}-hint`}
              className={cn(area, 'mt-3')}
            />
            <div className="mt-3 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
              <p id={`${id}-hint`} className="text-[12.5px] text-subtle-foreground max-sm:hidden">
                {t.shortcut}
              </p>
              <Button size="sm" iconLeft={busy ? 'loader' : 'plus'} disabled={!text.trim() || busy} onClick={() => void add()} className="max-sm:w-full">
                {busy ? t.adding : t.add}
              </Button>
            </div>
          </section>
          {/* Phones and tablets: the numbers as one row above the history */}
          <ul aria-label={t.inNumbers} className="crmc-up grid grid-cols-4 gap-px overflow-hidden rounded-xl border border-border bg-border [--crmc-d:60ms] lg:hidden">
            {stats.map(({ k, n }) => (
              <li key={k} className="flex flex-col items-center gap-1 bg-card px-1 py-3">
                <Icon name={TYPE[k].icon} size={15} className="text-muted-foreground" />
                <span className="text-[16px] font-semibold tabular-nums text-foreground">{n}</span>
                <span className="w-full truncate text-center text-[11.5px] text-muted-foreground">{t[`${k}s` as 'emails'] ?? t[k]}</span>
              </li>
            ))}
          </ul>
          <section aria-label={t.history} className={cn(cardCls, 'crmc-up min-w-0 p-4 [--crmc-d:120ms] sm:p-5')}>
            <Chips
              label={t.filter}
              value={filter}
              onChange={setFilter}
              options={[
                { value: 'all', label: t.all, count: items.length },
                { value: 'email', label: t.emails, count: counts('email') },
                { value: 'call', label: t.calls, count: counts('call') },
                { value: 'meeting', label: t.meetings, count: counts('meeting') },
                { value: 'note', label: t.notesTab, count: counts('note') },
              ]}
            />
            <div key={filter} className="crmc-in mt-4">
              <ActivityList items={shown} t={t} f={f} now={now} freshId={fresh} />
            </div>
          </section>
        </div>
        {/* Desktop: the contact and the numbers next to the history */}
        <aside className="hidden gap-5 lg:sticky lg:top-6 lg:grid">
          {contact && (
            <section aria-label={t.contactSheet} className={cn(cardCls, 'crmc-up p-5 [--crmc-d:80ms]')}>
              <PersonHead c={contact} t={t} size="lg" />
              <dl className="mt-4 grid gap-2 border-t border-border pt-4 text-[13px]">
                <div className="flex justify-between gap-3">
                  <dt className="text-muted-foreground">{t.lastContact}</dt>
                  <dd className="tabular-nums text-foreground">{contact.lastContact ? f.ago(contact.lastContact) : t.none}</dd>
                </div>
                <div className="flex justify-between gap-3">
                  <dt className="text-muted-foreground">{t.nextMeeting}</dt>
                  <dd className="text-right tabular-nums text-foreground">{contact.nextMeeting ? `${f.date(contact.nextMeeting, { weekday: 'short', day: 'numeric', month: 'short' })}, ${f.time(contact.nextMeeting)}` : t.none}</dd>
                </div>
              </dl>
            </section>
          )}
          <section className={cn(cardCls, 'crmc-up p-5 [--crmc-d:160ms]')}>
            <h2 className="text-[13px] font-semibold text-foreground">{t.inNumbers}</h2>
            <ul className="mt-4 grid gap-3.5">
              {stats.map(({ k, n }, i) => (
                <li key={k} className="grid gap-1.5">
                  <p className="flex items-center justify-between text-[13px]">
                    <span className="inline-flex items-center gap-2 text-muted-foreground">
                      <Icon name={TYPE[k].icon} size={14} />
                      {t[`${k}s` as 'emails'] ?? t[k]}
                    </span>
                    <span className="font-medium tabular-nums text-foreground">{n}</span>
                  </p>
                  <span className="block h-1.5 overflow-hidden rounded-full bg-track">
                    <span className="crmc-grow block h-full rounded-full bg-primary transition-[width] duration-500" style={{ width: `${Math.round((n / total) * 100)}%`, '--crmc-d': `${200 + i * 80}ms` } as React.CSSProperties} />
                  </span>
                </li>
              ))}
            </ul>
          </section>
        </aside>
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* deals                                                               */
/* ------------------------------------------------------------------ */

function Ladder({ d, stages, t, onChange }: { d: CrmDeal; stages: CrmStage[]; t: Texts; onChange: (stage: string) => void }) {
  const ref = useRef<HTMLDivElement>(null);
  const i = stageOf(stages, d.stage);
  const won = i === stages.length - 1;
  const go = (n: number) => {
    const k = Math.max(0, Math.min(stages.length - 1, n));
    onChange(stages[k].id);
    ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[k]?.focus();
  };
  return (
    <>
      {/* Tablet and desktop: every stage is a step you can pick */}
      <div ref={ref} role="radiogroup" aria-label={fill(t.stage, { name: d.name })} aria-disabled={d.lost || undefined} className="hidden grid-flow-col gap-1 sm:grid sm:auto-cols-fr">
        {stages.map((s, k) => {
          const on = k === i;
          const done = k <= i && !d.lost;
          return (
            <button
              key={s.id}
              type="button"
              role="radio"
              aria-checked={on}
              tabIndex={on ? 0 : -1}
              disabled={d.lost}
              onClick={() => go(k)}
              onKeyDown={(e) => {
                const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
                if (dir) (e.preventDefault(), go(k + dir));
                if (e.key === 'Home') (e.preventDefault(), go(0));
                if (e.key === 'End') (e.preventDefault(), go(stages.length - 1));
              }}
              className={cn('group relative min-w-0 cursor-pointer pt-3 text-left disabled:cursor-not-allowed', focusRing, 'rounded-sm')}
            >
              <span className={cn('absolute inset-x-0 top-0 h-1.5 rounded-full transition-[background-color] duration-500', done ? (won ? 'bg-success' : 'bg-primary') : 'bg-track group-hover:bg-[color-mix(in_oklab,var(--foreground)_22%,transparent)]')} />
              <span className={cn('block truncate text-[12px] transition-colors', on ? 'font-semibold text-foreground' : 'text-muted-foreground group-hover:text-foreground')}>{s.label}</span>
            </button>
          );
        })}
      </div>
      {/* Phones: the bar to read, one step back or on as buttons */}
      <div className="grid gap-2 sm:hidden">
        <div className="flex gap-1" aria-hidden>
          {stages.map((s, k) => (
            <span key={s.id} className={cn('h-1.5 flex-1 rounded-full transition-colors duration-500', k <= i && !d.lost ? (won ? 'bg-success' : 'bg-primary') : 'bg-track')} />
          ))}
        </div>
        <div className="flex items-center justify-between gap-2">
          <button type="button" aria-label={t.prevStage} disabled={d.lost || i === 0} onClick={() => go(i - 1)} className={cn(iconBtn, 'border border-border disabled:opacity-40')}>
            <Icon name="chevron-left" size={16} />
          </button>
          <span className="min-w-0 truncate text-center text-[13px] font-medium text-foreground" aria-live="polite">
            {d.lost ? t.lostTag : stages[i]?.label}
          </span>
          <button type="button" aria-label={t.nextStage} disabled={d.lost || won} onClick={() => go(i + 1)} className={cn(iconBtn, 'border border-border disabled:opacity-40')}>
            <Icon name="chevron-right" size={16} />
          </button>
        </div>
      </div>
    </>
  );
}

function Deals({ t, f, title, subtitle, deals = [], stages = [], me, onAction }: P) {
  const id = useId();
  const [items, setItems] = useState(deals);
  const [msg, setMsg] = useState('');
  const [form, setForm] = useState({ name: '', value: '', stage: stages[0]?.id ?? '' });
  const [tried, setTried] = useState(false);
  const [fresh, setFresh] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const last = stages.length - 1;
  const isWon = (d: CrmDeal) => !d.lost && stageOf(stages, d.stage) === last;
  const open = items.filter((d) => !d.lost && !isWon(d));
  const pipeline = open.reduce((s, d) => s + d.value, 0);
  const weighted = open.reduce((s, d) => s + (d.value * probOf(d, stages)) / 100, 0);
  const wonSum = items.filter(isWon).reduce((s, d) => s + d.value, 0);
  const closed = items.filter((d) => d.lost || isWon(d)).length;
  const rate = closed ? (items.filter(isWon).length / closed) * 100 : 0;
  const update = (d: CrmDeal, patch: Partial<CrmDeal>, text: string) => {
    setItems((l) => l.map((x) => (x.id === d.id ? { ...x, ...patch } : x)));
    setMsg(text);
    void run(onAction, 'update-deal', { id: d.id, ...patch });
  };
  const num = Number(form.value.replace(/[^\d,.-]/g, '').replace(/[.,](?=\d{3}(\D|$))/g, '').replace(',', '.'));
  const errName = tried && !form.name.trim();
  const errValue = tried && !(num > 0);
  const create = async () => {
    setTried(true);
    if (!form.name.trim() || !(num > 0)) {
      requestAnimationFrame(() => document.getElementById(!form.name.trim() ? `${id}-dn` : `${id}-dv`)?.focus());
      return;
    }
    const d: CrmDeal = { id: `d${Date.now()}`, name: form.name.trim(), value: num, stage: form.stage, owner: me };
    await run(onAction, 'create-deal', { deal: d });
    setItems((l) => [...l, d]);
    setFresh(d.id);
    setForm({ name: '', value: '', stage: stages[0]?.id ?? '' });
    setTried(false);
    setMsg(fill(t.dealCreated, { name: d.name }));
  };
  const tiles = [
    { label: t.pipeline, value: f.money(pipeline) },
    { label: t.weighted, value: f.money(Math.round(weighted)) },
    { label: t.won, value: f.money(wonSum) },
    { label: t.winRate, value: f.pct(rate) },
  ];
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="grid gap-5">
        <ul className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border lg:grid-cols-4">
          {tiles.map((x, i) => (
            <li key={x.label} className="bg-card px-4 py-3.5 sm:px-5 sm:py-4">
              <div className="crmc-up" style={{ '--crmc-d': `${i * 70}ms` } as React.CSSProperties}>
                <p className="text-[12.5px] text-muted-foreground">{x.label}</p>
                <p key={x.value} className="crmc-in mt-1 text-[18px] font-semibold tracking-[-0.02em] tabular-nums text-foreground sm:text-[22px]">
                  {x.value}
                </p>
              </div>
            </li>
          ))}
        </ul>
        {items.length ? (
          <ul className="grid gap-3">
            {items.map((d, i) => {
              const p = probOf(d, stages);
              const won = isWon(d);
              return (
                <li key={d.id} className={cn(cardCls, 'crmc-up p-4 transition-[border-color,box-shadow,opacity] duration-300 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] sm:p-5', d.lost && 'opacity-70', d.id === fresh && 'crmc-in crmc-flash')} style={{ '--crmc-d': `${120 + i * 70}ms` } as React.CSSProperties}>
                  <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1">
                    <h2 className="flex min-w-0 flex-wrap items-center gap-2 text-[15px] font-semibold text-foreground">
                      <span className="min-w-0 truncate">{d.name}</span>
                      {won && <span className="inline-flex h-5 items-center gap-1 rounded bg-[color-mix(in_oklab,var(--success)_14%,transparent)] px-1.5 text-[11px] font-semibold text-success-foreground"><Icon name="trophy" size={11} />{t.wonTag}</span>}
                      {d.lost && <span className="inline-flex h-5 items-center rounded bg-muted px-1.5 text-[11px] font-semibold text-muted-foreground">{t.lostTag}</span>}
                    </h2>
                    <p className="row-span-2 text-right">
                      <span className="block text-[18px] font-semibold tracking-[-0.02em] tabular-nums text-foreground sm:text-[20px]">{f.money(d.value)}</span>
                      <span className="block text-[12.5px] tabular-nums text-muted-foreground">
                        {t.weighted} {f.money(Math.round((d.value * p) / 100))}
                      </span>
                    </p>
                    <p className="text-[12.5px] tabular-nums text-muted-foreground">{[d.owner, d.close ? fill(t.closes, { date: f.date(d.close, { day: 'numeric', month: 'short', year: 'numeric' }) }) : ''].filter(Boolean).join(' · ')}</p>
                  </div>
                  <div className="mt-4">
                    <Ladder d={d} stages={stages} t={t} onChange={(s) => update(d, { stage: s, probability: stages.find((x) => x.id === s)?.probability }, fill(t.stageNow, { name: d.name, stage: stages.find((x) => x.id === s)?.label ?? s }))} />
                  </div>
                  <div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-border pt-3">
                    <p className="flex min-w-0 flex-1 items-center gap-3 text-[12.5px] text-muted-foreground">
                      <span className="shrink-0 tabular-nums">{fill(t.chance, { p: f.pct(p) })}</span>
                      <span className="block h-1.5 w-full max-w-40 overflow-hidden rounded-full bg-track">
                        <span className={cn('block h-full rounded-full transition-[width] duration-500', won ? 'bg-success' : 'bg-primary')} style={{ width: `${p}%` }} />
                      </span>
                    </p>
                    {!won && (
                      <Button size="sm" variant="ghost" iconLeft={d.lost ? 'rotate-ccw' : 'x-circle'} onClick={() => update(d, { lost: !d.lost }, fill(d.lost ? t.reopened : t.lostNow, { name: d.name }))}>
                        {d.lost ? t.reopen : t.markLost}
                      </Button>
                    )}
                  </div>
                </li>
              );
            })}
          </ul>
        ) : (
          <p className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.noDeals}</p>
        )}
        <form
          noValidate
          aria-labelledby={`${id}-new`}
          onSubmit={(e) => {
            e.preventDefault();
            void create();
          }}
          className={cn(cardCls, 'crmc-up border-dashed p-4 sm:p-5')}
        >
          <h2 id={`${id}-new`} className="text-[14px] font-semibold text-foreground">
            {t.newDeal}
          </h2>
          <div className="mt-3 grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1fr)_auto] lg:items-start">
            <label className="grid gap-1.5 text-[12.5px] font-medium text-muted-foreground">
              {t.dealName}
              <input id={`${id}-dn`} value={form.name} aria-invalid={errName} aria-describedby={errName ? `${id}-en` : undefined} onChange={(e) => setForm({ ...form, name: e.target.value })} className={field} />
              {errName && <span id={`${id}-en`} className="text-[12.5px] font-normal text-destructive">{t.nameNeeded}</span>}
            </label>
            <label className="grid gap-1.5 text-[12.5px] font-medium text-muted-foreground">
              {t.dealValue}
              <input id={`${id}-dv`} inputMode="decimal" value={form.value} aria-invalid={errValue} aria-describedby={errValue ? `${id}-ev` : undefined} onChange={(e) => setForm({ ...form, value: e.target.value })} onBlur={() => num > 0 && setForm((x) => ({ ...x, value: f.num(num) }))} className={cn(field, 'tabular-nums')} />
              {errValue && <span id={`${id}-ev`} className="text-[12.5px] font-normal text-destructive">{t.valueNeeded}</span>}
            </label>
            <label className="grid gap-1.5 text-[12.5px] font-medium text-muted-foreground">
              {t.dealStage}
              <span className="relative block">
                <select value={form.stage} onChange={(e) => setForm({ ...form, stage: e.target.value })} className={cn(field, 'cursor-pointer appearance-none pe-8')}>
                  {stages.slice(0, -1).map((s) => (
                    <option key={s.id} value={s.id}>
                      {s.label}
                    </option>
                  ))}
                </select>
                <Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
              </span>
            </label>
            <Button type="submit" size="sm" iconLeft="plus" className="sm:col-span-2 lg:col-span-1 lg:mt-[1.4rem]">
              {t.createDeal}
            </Button>
          </div>
        </form>
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* notes                                                               */
/* ------------------------------------------------------------------ */

/** Textarea with @ mentions after the APG combobox pattern */
function MentionBox({ id, value, onChange, people, label, t, rows = 3, onSubmit, onEscape, autoFocus }: { id: string; value: string; onChange: (v: string) => void; people: string[]; label: string; t: Texts; rows?: number; onSubmit?: () => void; onEscape?: () => void; autoFocus?: boolean }) {
  const ref = useRef<HTMLTextAreaElement>(null);
  const [query, setQuery] = useState<string | null>(null);
  const [active, setActive] = useState(0);
  const list = query === null ? [] : people.filter((p) => p.toLowerCase().includes(query.toLowerCase())).slice(0, 5);
  const open = list.length > 0;
  useEffect(() => {
    if (autoFocus) ref.current?.focus();
  }, [autoFocus]);
  const sense = (el: HTMLTextAreaElement) => {
    const before = el.value.slice(0, el.selectionStart ?? el.value.length);
    const m = /(?:^|\s)@([\p{L}]*(?: [\p{L}]*)?)$/u.exec(before);
    setQuery(m ? m[1] : null);
    setActive(0);
  };
  const pick = (name: string) => {
    const el = ref.current;
    if (!el) return;
    const at = el.selectionStart ?? value.length;
    const before = value.slice(0, at).replace(/@([\p{L}]*(?: [\p{L}]*)?)$/u, `@${name} `);
    const next = before + value.slice(at);
    onChange(next);
    setQuery(null);
    requestAnimationFrame(() => {
      el.focus();
      el.setSelectionRange(before.length, before.length);
    });
  };
  return (
    <div className="relative">
      <label htmlFor={id} className="sr-only">
        {label}
      </label>
      <textarea
        ref={ref}
        id={id}
        rows={rows}
        value={value}
        placeholder={label}
        role="combobox"
        aria-autocomplete="list"
        aria-expanded={open}
        aria-controls={`${id}-list`}
        aria-activedescendant={open ? `${id}-o${active}` : undefined}
        onChange={(e) => {
          onChange(e.target.value);
          sense(e.target);
        }}
        onClick={(e) => sense(e.currentTarget)}
        onBlur={() => setTimeout(() => setQuery(null), 120)}
        onKeyDown={(e) => {
          if (open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
            e.preventDefault();
            setActive((a) => (a + (e.key === 'ArrowDown' ? 1 : -1) + list.length) % list.length);
            return;
          }
          if (open && (e.key === 'Enter' || e.key === 'Tab')) {
            e.preventDefault();
            pick(list[active]);
            return;
          }
          if (e.key === 'Escape') {
            if (open) {
              e.preventDefault();
              setQuery(null);
            } else onEscape?.();
            return;
          }
          if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
            e.preventDefault();
            onSubmit?.();
          }
        }}
        className={area}
      />
      <ul id={`${id}-list`} role="listbox" aria-label={t.suggestions} className={cn('absolute inset-x-0 top-full z-20 mt-1.5 max-w-xs overflow-hidden rounded-lg border border-border bg-card p-1 shadow-[var(--shadow-lg)]', open ? 'crmc-in' : 'invisible hidden')}>
        {list.map((p, i) => (
          <li
            key={p}
            id={`${id}-o${i}`}
            role="option"
            aria-selected={i === active}
            onMouseDown={(e) => {
              e.preventDefault();
              pick(p);
            }}
            onMouseEnter={() => setActive(i)}
            className={cn('flex min-h-11 cursor-pointer items-center gap-2.5 rounded-md px-2 text-[13.5px] text-foreground sm:min-h-9', i === active && 'bg-accent')}
          >
            <Avatar name={p} size="xs" />
            {p}
          </li>
        ))}
      </ul>
    </div>
  );
}

function Notes({ t, f, title, subtitle, notes = [], people = [], me, now, onAction }: P) {
  const id = useId();
  const [items, setItems] = useState(notes);
  const [draft, setDraft] = useState('');
  const [editing, setEditing] = useState<string | null>(null);
  const [editText, setEditText] = useState('');
  const [who, setWho] = useState<string>('all');
  const [q, setQ] = useState('');
  const [msg, setMsg] = useState('');
  const [removed, setRemoved] = useState<{ note: CrmNote; at: number } | null>(null);
  const [fresh, setFresh] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const mentionsOf = (n: CrmNote) => people.filter((p) => n.body.includes(`@${p}`));
  const shown = items.filter((n) => (who === 'all' || mentionsOf(n).includes(who)) && (!q || n.body.toLowerCase().includes(q.toLowerCase()) || n.author.toLowerCase().includes(q.toLowerCase())));
  const pinned = shown.filter((n) => n.pinned).sort((a, b) => toMs(b.time) - toMs(a.time));
  const rest = shown.filter((n) => !n.pinned).sort((a, b) => toMs(b.time) - toMs(a.time));
  const counts = people.map((p) => ({ p, n: items.filter((x) => mentionsOf(x).includes(p)).length })).filter((x) => x.n > 0);
  const save = async () => {
    const body = draft.trim();
    if (!body) return;
    const n: CrmNote = { id: `n${Date.now()}`, body, author: me, time: now };
    await run(onAction, 'add-note', { note: n }, 300);
    setItems((l) => [n, ...l]);
    setFresh(n.id);
    setDraft('');
    setMsg(t.noteSaved);
  };
  const toggle = (n: CrmNote) => {
    setItems((l) => l.map((x) => (x.id === n.id ? { ...x, pinned: !x.pinned } : x)));
    setFresh(n.id);
    setMsg(n.pinned ? t.unpin : t.pin);
    void run(onAction, 'pin-note', { id: n.id, pinned: !n.pinned });
  };
  const remove = (n: CrmNote) => {
    setRemoved({ note: n, at: items.indexOf(n) });
    setItems((l) => l.filter((x) => x.id !== n.id));
    setMsg(t.deleted);
    void run(onAction, 'delete-note', { id: n.id });
    requestAnimationFrame(() => document.getElementById(`${id}-undo`)?.focus());
  };
  const commit = (n: CrmNote) => {
    const body = editText.trim();
    if (!body) return;
    setItems((l) => l.map((x) => (x.id === n.id ? { ...x, body } : x)));
    setEditing(null);
    setFresh(n.id);
    setMsg(t.edited);
    void run(onAction, 'edit-note', { id: n.id, body });
    requestAnimationFrame(() => document.getElementById(`${id}-e-${n.id}`)?.focus());
  };
  const card = (n: CrmNote) => (
    <li key={n.id} className={cn('group rounded-xl border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,translate,box-shadow] duration-300 hover:-translate-y-0.5 hover:shadow-[var(--highlight),var(--shadow-md)] sm:p-5', n.pinned ? 'border-[color-mix(in_oklab,var(--primary)_35%,var(--border))]' : 'border-border hover:border-border-strong', n.id === fresh && 'crmc-in')}>
      <div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-3">
        <Avatar name={n.author} size="sm" />
        <p className="flex min-w-0 flex-wrap items-baseline gap-x-2 pt-1 text-[13px] max-sm:flex-col">
          <span className="font-medium text-foreground">{n.author}</span>
          <time dateTime={n.time} className="tabular-nums text-subtle-foreground">
            {f.date(n.time, { day: 'numeric', month: 'short' })}, {f.time(n.time)}
          </time>
        </p>
        {editing !== n.id ? (
          <div role="group" aria-label={t.notes} className="-me-2 -mt-2 flex shrink-0 opacity-100 transition-opacity duration-200 sm:opacity-60 sm:group-focus-within:opacity-100 sm:group-hover:opacity-100">
            <button type="button" aria-label={n.pinned ? t.unpin : t.pin} aria-pressed={!!n.pinned} onClick={() => toggle(n)} className={cn(iconBtn, n.pinned && 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>
              <Icon name="pin" size={15} />
            </button>
            <button type="button" id={`${id}-e-${n.id}`} aria-label={t.edit} onClick={() => (setEditing(n.id), setEditText(n.body))} className={iconBtn}>
              <Icon name="edit" size={15} />
            </button>
            <button type="button" aria-label={t.del} onClick={() => remove(n)} className={cn(iconBtn, 'hover:text-destructive')}>
              <Icon name="trash" size={15} />
            </button>
          </div>
        ) : (
          <span />
        )}
        <div className="col-span-3 min-w-0 sm:col-span-2 sm:col-start-2">
          {editing === n.id ? (
            <div className="mt-3 grid gap-2 sm:mt-1.5">
              <MentionBox id={`${id}-edit-${n.id}`} value={editText} onChange={setEditText} people={people} label={t.edit} t={t} onSubmit={() => commit(n)} onEscape={() => setEditing(null)} autoFocus />
              <div className="flex flex-wrap gap-2">
                <Button size="sm" iconLeft="check" disabled={!editText.trim()} onClick={() => commit(n)}>
                  {t.save}
                </Button>
                <Button size="sm" variant="ghost" onClick={() => (setEditing(null), requestAnimationFrame(() => document.getElementById(`${id}-e-${n.id}`)?.focus()))}>
                  {t.cancel}
                </Button>
              </div>
            </div>
          ) : (
            <p className="mt-3 whitespace-pre-line text-pretty text-[14px] leading-relaxed text-foreground sm:mt-1">
              <Mentioned text={n.body} people={people} />
            </p>
          )}
        </div>
      </div>
    </li>
  );
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-start">
        <div className="grid min-w-0 gap-5">
          <form
            onSubmit={(e) => {
              e.preventDefault();
              void save();
            }}
            className={cn(cardCls, 'crmc-up p-4 sm:p-5')}
          >
            <MentionBox id={`${id}-new`} value={draft} onChange={setDraft} people={people} label={t.writeNote} t={t} onSubmit={() => void save()} />
            <div className="mt-3 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
              <p className="text-[12.5px] text-subtle-foreground max-sm:hidden">{t.shortcut}</p>
              <Button type="submit" size="sm" iconLeft="check" disabled={!draft.trim()} className="max-sm:w-full">
                {t.saveNote}
              </Button>
            </div>
          </form>
          {removed && (
            <p className="crmc-in flex items-center justify-between gap-3 rounded-lg border border-border bg-surface px-4 py-2 text-[13.5px] text-foreground">
              {t.deleted}
              <Button
                id={`${id}-undo`}
                size="sm"
                variant="ghost"
                iconLeft="undo"
                onClick={() => {
                  setItems((l) => [...l.slice(0, removed.at), removed.note, ...l.slice(removed.at)]);
                  setFresh(removed.note.id);
                  setRemoved(null);
                  setMsg(t.restored);
                }}
              >
                {t.undo}
              </Button>
            </p>
          )}
          {/* Phones and tablets: the mention filter as chips above the notes */}
          <label className="relative block lg:hidden">
            <span className="sr-only">{t.searchNotes}</span>
            <Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
            <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.searchNotes} className={cn(field, 'ps-9')} />
          </label>
          {counts.length > 0 && <Chips className="-mt-2 lg:hidden" label={t.mentions} value={who} onChange={setWho} options={[{ value: 'all', label: t.allNotes, count: items.length }, ...counts.map((x) => ({ value: x.p, label: `@${x.p}`, count: x.n }))]} />}
          <div key={who + q} className="grid gap-5">
            {pinned.length > 0 && (
              <section aria-labelledby={`${id}-p`} className="crmc-up">
                <h2 id={`${id}-p`} className="mb-2.5 flex items-center gap-2 text-[12px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">
                  <Icon name="pin" size={13} />
                  {t.pinned}
                </h2>
                <ul className="grid gap-3">{pinned.map(card)}</ul>
              </section>
            )}
            {rest.length > 0 && (
              <section aria-labelledby={`${id}-r`} className="crmc-up [--crmc-d:100ms]">
                <h2 id={`${id}-r`} className="mb-2.5 text-[12px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">
                  {pinned.length ? t.others : t.notes}
                </h2>
                <ul className="grid gap-3">{rest.map(card)}</ul>
              </section>
            )}
            {!shown.length && <p className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.noNotes}</p>}
          </div>
        </div>
        {/* Desktop: search and the mention filter beside the notes */}
        <aside className="hidden gap-5 lg:sticky lg:top-6 lg:grid">
          <label className="crmc-up relative block">
            <span className="sr-only">{t.searchNotes}</span>
            <Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
            <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.searchNotes} className={cn(field, 'ps-9')} />
          </label>
          {counts.length > 0 && (
            <section className={cn(cardCls, 'crmc-up p-2 [--crmc-d:100ms]')}>
              <h2 className="px-3 pb-1 pt-2 text-[12px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{t.mentions}</h2>
              <ul className="grid">
                {[{ p: 'all', n: items.length }, ...counts].map((x) => (
                  <li key={x.p}>
                    <button type="button" aria-pressed={who === x.p} aria-label={x.p === 'all' ? t.allNotes : fill(t.mentionsOf, { name: x.p })} onClick={() => setWho(x.p)} className={cn('flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-md px-3 text-left text-[13.5px] transition-colors', focusRing, who === x.p ? 'bg-accent font-medium text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}>
                      {x.p === 'all' ? <Icon name="list" size={15} className="mx-[4px]" /> : <Avatar name={x.p} size="xs" />}
                      <span className="min-w-0 flex-1 truncate">{x.p === 'all' ? t.allNotes : x.p}</span>
                      <span className="text-[12px] tabular-nums text-subtle-foreground">{x.n}</span>
                    </button>
                  </li>
                ))}
              </ul>
            </section>
          )}
        </aside>
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* list-detail                                                         */
/* ------------------------------------------------------------------ */

function ListDetail({ t, f, title, subtitle, contacts = [], activities = [], deals = [], stages = [], now, onAction }: P) {
  const id = useId();
  const [q, setQ] = useState('');
  const [seg, setSeg] = useState<'all' | 'customer' | 'lead'>('all');
  const [sel, setSel] = useState(contacts[0]?.id ?? '');
  // Phones show the list or the detail, never both
  const [phoneDetail, setPhoneDetail] = useState(false);
  const [msg, setMsg] = useState('');
  const listRef = useRef<HTMLUListElement>(null);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const shown = contacts.filter((c) => (seg === 'all' || c.status === seg) && (!q || [c.name, c.company, c.location, c.role].filter(Boolean).join(' ').toLowerCase().includes(q.toLowerCase())));
  const c = contacts.find((x) => x.id === sel);
  const choose = (cid: string, open = false) => {
    setSel(cid);
    if (open) {
      setPhoneDetail(true);
      requestAnimationFrame(() => document.getElementById(`${id}-head`)?.focus());
    }
  };
  const onKey = (e: React.KeyboardEvent) => {
    const i = shown.findIndex((x) => x.id === sel);
    let n = -1;
    if (e.key === 'ArrowDown') n = Math.min(shown.length - 1, i + 1);
    if (e.key === 'ArrowUp') n = Math.max(0, i - 1);
    if (e.key === 'Home') n = 0;
    if (e.key === 'End') n = shown.length - 1;
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      if (sel) choose(sel, true);
      return;
    }
    if (n < 0 || !shown[n]) return;
    e.preventDefault();
    setSel(shown[n].id);
    listRef.current?.querySelector<HTMLElement>(`[data-id="${shown[n].id}"]`)?.scrollIntoView({ block: 'nearest' });
  };
  const acts = c ? activities.filter((a) => a.contactId === c.id).sort((a, b) => toMs(b.time) - toMs(a.time)).slice(0, 3) : [];
  const own = c ? deals.filter((d) => d.contactId === c.id && !d.lost && stageOf(stages, d.stage) < stages.length - 1) : [];
  return (
    <Main className="lg:p-6">
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className={cn(cardCls, 'crmc-up grid min-h-[38rem] grid-cols-[minmax(0,1fr)] overflow-hidden sm:grid-cols-[17rem_minmax(0,1fr)] lg:grid-cols-[21rem_minmax(0,1fr)]')}>
        {/* The list */}
        <section aria-label={t.contacts} className={cn('min-w-0 flex-col border-border sm:flex sm:border-e', phoneDetail ? 'hidden' : 'flex')}>
          <div className="grid gap-3 border-b border-border p-3 sm:p-4">
            <label className="relative block">
              <span className="sr-only">{t.search}</span>
              <Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
              <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className={cn(field, 'ps-9')} />
            </label>
            <Chips
              label={t.filter}
              value={seg}
              onChange={setSeg}
              options={[
                { value: 'all', label: t.all, count: contacts.length },
                { value: 'customer', label: t.customer, count: contacts.filter((x) => x.status === 'customer').length },
                { value: 'lead', label: t.lead, count: contacts.filter((x) => x.status === 'lead').length },
              ]}
              className="-mx-3 px-3 sm:-mx-4 sm:flex-nowrap sm:overflow-x-auto sm:px-4"
            />
          </div>
          <p className="sr-only" aria-live="polite">
            {fill(t.count, { n: shown.length })}
          </p>
          {shown.length ? (
            <ul ref={listRef} role="listbox" aria-label={t.contacts} aria-activedescendant={sel ? `${id}-o-${sel}` : undefined} tabIndex={0} onKeyDown={onKey} className="relative min-h-0 flex-1 overflow-y-auto p-1.5 outline-none focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_50%,transparent)] sm:max-h-[34rem]">
              {shown.map((x, i) => {
                const on = x.id === sel;
                return (
                  <li key={x.id} id={`${id}-o-${x.id}`} data-id={x.id} role="option" aria-selected={on} onClick={() => choose(x.id, true)} className={cn('crmc-fly group flex min-h-14 cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 transition-colors', on ? 'bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))]' : 'hover:bg-accent')} style={{ '--crmc-d': `${i * 40}ms` } as React.CSSProperties}>
                    <Avatar name={x.name} size="md" />
                    <span className="min-w-0 flex-1">
                      <span className="flex items-center gap-2">
                        <span className={cn('truncate text-[14px] text-foreground', on ? 'font-semibold' : 'font-medium')}>{x.name}</span>
                        <span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', x.status === 'customer' ? 'bg-success' : x.status === 'lead' ? 'bg-primary' : 'bg-subtle-foreground')} />
                      </span>
                      <span className="block truncate text-[12.5px] text-muted-foreground">{x.company}</span>
                    </span>
                    <span className="shrink-0 text-[12px] tabular-nums text-subtle-foreground">{x.lastContact ? f.ago(x.lastContact) : ''}</span>
                    <Icon name="chevron-right" size={15} className="shrink-0 text-subtle-foreground transition-transform group-hover:translate-x-0.5 sm:hidden" />
                  </li>
                );
              })}
            </ul>
          ) : (
            <p className="px-6 py-12 text-center text-[13.5px] text-muted-foreground">{t.noMatch}</p>
          )}
        </section>
        {/* The detail */}
        <section aria-labelledby={`${id}-head`} className={cn('min-w-0 flex-col bg-surface sm:flex', phoneDetail ? 'flex' : 'hidden')}>
          {c ? (
            <div key={c.id} className="crmc-in flex flex-col gap-5 p-4 sm:p-6">
              <button type="button" onClick={() => (setPhoneDetail(false), requestAnimationFrame(() => listRef.current?.focus()))} className={cn('-ms-2 inline-flex h-11 cursor-pointer items-center gap-1.5 self-start rounded-md px-2 text-[13.5px] font-medium text-muted-foreground hover:text-foreground sm:hidden', focusRing)}>
                <Icon name="arrow-left" size={16} />
                {t.back}
              </button>
              <div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
                <div id={`${id}-head`} tabIndex={-1} className="rounded-md outline-none">
                  <PersonHead c={c} t={t} size="lg" />
                </div>
                <QuickActions
                  c={c}
                  t={t}
                  className="xl:w-[22rem]"
                  onMeeting={async () => {
                    await run(onAction, 'schedule', { id: c.id });
                    setMsg(fill(t.scheduled, { name: c.name }));
                  }}
                />
              </div>
              <div className="grid grid-cols-[minmax(0,1fr)] gap-5 xl:grid-cols-2">
                <section className={cn(cardCls, 'px-4 py-1.5')}>
                  <h3 className="sr-only">{t.details}</h3>
                  <Fields rows={detailRows(c, t, f)} />
                </section>
                <div className="grid content-start gap-5">
                  <section className={cn(cardCls, 'p-4')}>
                    <h3 className="flex items-center justify-between text-[13px] font-semibold text-foreground">
                      {t.openDeals}
                      <span className="tabular-nums text-muted-foreground">{f.money(own.reduce((s, d) => s + d.value, 0))}</span>
                    </h3>
                    {own.length ? (
                      <ul className="mt-3 grid gap-2">
                        {own.map((d) => (
                          <DealRow key={d.id} d={d} stages={stages} t={t} f={f} />
                        ))}
                      </ul>
                    ) : (
                      <p className="mt-2 text-[13px] text-muted-foreground">{t.noDeals}</p>
                    )}
                  </section>
                </div>
              </div>
              <section className={cn(cardCls, 'p-4')}>
                <h3 className="text-[13px] font-semibold text-foreground">{t.recent}</h3>
                <div className="mt-2">
                  <ActivityList items={acts} t={t} f={f} now={now} compact />
                </div>
              </section>
            </div>
          ) : (
            <p className="m-auto px-6 py-16 text-center text-[13.5px] text-muted-foreground">{t.noContact}</p>
          )}
        </section>
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* CrmContact                                                          */
/* ------------------------------------------------------------------ */

/**
 * A contact in a CRM: the profile with tabs, the history with filters and
 * new entries, the deals with their stage as a ladder, notes with mentions,
 * and a contact list with the detail next to it.
 *   <CrmContact variant="profile" contact={…} activities={…} deals={…} stages={…} notes={…} />
 */
export function CrmContact({ variant = 'profile', sidebar, labels, className, now = '2026-10-23T10:30', currency = 'EUR', me = 'Lena Hoffmann', ...props }: CrmContactProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const f = useMemo(() => makeFmt(locale, currency, now), [locale, currency, now]);
  const p: P = { ...props, t, now, locale, f, me, currency };
  const body = (() => {
    switch (variant) {
      case 'timeline':
        return <Timeline {...p} />;
      case 'deals':
        return <Deals {...p} />;
      case 'notes':
        return <Notes {...p} />;
      case 'list-detail':
        return <ListDetail {...p} />;
      default:
        return <Profile {...p} />;
    }
  })();
  if (!sidebar)
    return (
      <div className={cn('min-h-dvh bg-background', className)}>
        <Style />
        {body}
      </div>
    );
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      <Style />
      {body}
    </Sidebar>
  );
}

Häufige Fragen

Wie installiere ich CRM-Kontakt?

Mit aiw add @ai-website/crm-contact. Der Befehl legt die Datei components/ui/crm-contact.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind avatar, button, icon, motion und sidebar. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat CRM-Kontakt?

5 Varianten, Kontaktprofil, Kontaktverlauf, Deals des Kontakts, Kontaktnotizen und Kontaktliste mit Detail. Du wählst sie mit der Prop variant, zum Beispiel variant="timeline".

Kann ich die Texte von CRM-Kontakt übersetzen?

Ja. CRM-Kontakt bringt 107 eigene Texte auf Deutsch und Englisch mit, etwa „E-Mail“, „Anruf“ und „Termin“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent CRM-Kontakt einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini CRM-Kontakt mit aiw_components und installieren sie mit aiw_add @ai-website/crm-contact. Nenn im Prompt die Variante, etwa variant="profile", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert