feat: Interviews, Mail-Papierkorb, Kontaktart

Drei Aenderungen aus dem Alltagsgebrauch:

- Der Kalender heisst jetzt "Interviews" - dafuer wird er benutzt. Der Wert in
  ViewState wandert mit, damit Menue und Kopfzeile nicht auseinanderlaufen.
  Sichtbare Reste ("Kalenderfarbe") heissen jetzt Terminfarbe.

- Mails haben einen Papierkorb, der unter dem Menuepunkt aufklappt. Loeschen
  heisst dabei ausschliesslich: im CRM. Im Postfach crm@perinco.de bleibt die
  Mail unberuehrt - Schreibrechte darauf hat die Anwendung nicht. Damit der
  Delta-Abgleich geloeschte Mails nicht wieder einliest, bleibt nach dem Leeren
  eine leere Zeile als Merker stehen (crm_purged); runMailSync() ueberspringt
  alles, was im CRM geloescht ist. Zurueckholen und Leeren darf jeder
  Angemeldete, Leeren nach einer Warnung.

- Der Kontaktstatus (Neu .. Verloren) wird zur Kontaktart: Recruiter, Partner,
  Endkunde. Die Spalte heisst jetzt kind, weil "Status" das Feld falsch
  benannte. migrateLeadKind() stellt bestehende Datenbanken um und setzt alle
  vorhandenen Kontakte auf Recruiter - welche Art jemand ist, sagt der alte
  Status nicht (so mit dem Nutzer entschieden).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-18 11:12:49 +02:00
co-authored by Claude Opus 5
parent 025390e18a
commit 8741a9ae0c
39 changed files with 719 additions and 199 deletions
+14 -5
View File
@@ -87,7 +87,7 @@ const App: React.FC = () => {
if (!silent) setIsDataLoading(true);
try {
// Die Benutzerliste braucht jeder: Netzwerkdiagramm, Zustaendigkeit,
// @-Mentions, Teilnehmer und Farben im Kalender. /users/directory ist
// @-Mentions, Teilnehmer und Farben der Termine. /users/directory ist
// deshalb - anders als /users - fuer alle Rollen freigegeben.
const [leadRows, connectionRows, eventRows, userRows, opportunityRows] = await Promise.all([
leadsApi.list(),
@@ -118,7 +118,7 @@ const App: React.FC = () => {
if (!user) return <LoginScreen />;
/*
* Kontakte und Vorbereich trennen. Ueberall im CRM - Opportunities, Kalender,
* Kontakte und Vorbereich trennen. Ueberall im CRM - Opportunities, Interviews,
* Netzwerk, Mailzuordnung - gilt nur der gepflegte Bestand; die Funde aus dem
* Postfach sind Rohmaterial und sollen in keiner Auswahlliste auftauchen.
*/
@@ -139,6 +139,7 @@ const App: React.FC = () => {
// mit, deshalb steht hier nur, worum es auf der Seite geht.
const STATIC_SUBTITLES: Partial<Record<ViewState, string>> = {
Mails: 'Postfach crm@perinco.de - Mails zuordnen und Kontakte übernehmen',
Papierkorb: 'Im CRM gelöschte Mails - im Postfach crm@perinco.de bleiben sie erhalten',
Scanner: 'Projekt Scanner - Suchagenten, Portale und Projektübereinstimmungen',
Unpassend: 'Projekt Scanner - als „passt nicht" markierte Treffer',
Suchagenten: 'Projekt Scanner - Beraterprofile mit CV und Suchbegriffen',
@@ -159,8 +160,8 @@ const App: React.FC = () => {
? `${mailPoolLeads.length} ${
mailPoolLeads.length === 1 ? 'Fund' : 'Funde'
} aus dem Postfach · noch nicht in den Kontakten`
: currentView === 'Kalender'
? `Gemeinsamer Teamkalender · ${events.length} ${
: currentView === 'Interviews'
? `Gemeinsame Termine im Team · ${events.length} ${
events.length === 1 ? 'Termin' : 'Termine'
} · ${upcomingCount} anstehend`
: STATIC_SUBTITLES[currentView];
@@ -193,7 +194,7 @@ const App: React.FC = () => {
reload={() => loadData(true)}
/>
)}
{currentView === 'Kalender' && (
{currentView === 'Interviews' && (
<Calendar
events={events}
leads={leads}
@@ -226,6 +227,14 @@ const App: React.FC = () => {
{currentView === 'Mails' && (
<Mails leads={leads} opportunities={opportunities} reload={() => loadData(true)} />
)}
{currentView === 'Papierkorb' && (
<Mails
leads={leads}
opportunities={opportunities}
reload={() => loadData(true)}
mode="papierkorb"
/>
)}
{currentView === 'Scanner' && <ScannerDashboard />}
{currentView === 'Unpassend' && <ScannerRejected />}
{currentView === 'Suchagenten' && <ScannerAgents />}
+21 -3
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { CONTACT_VIEWS, SCANNER_VIEWS, type ViewState } from '../types';
import { CONTACT_VIEWS, MAIL_VIEWS, SCANNER_VIEWS, type ViewState } from '../types';
import { UserAvatar } from './ui/UserAvatar';
import { useAuth } from '../lib/auth';
@@ -51,6 +51,22 @@ const CONTACTS_GROUP: MenuGroup = {
],
};
const TRASH_ICON =
'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16';
/**
* Mails. Darunter klappt der Papierkorb auf: Mails, die im CRM geloescht wurden.
* Im Postfach bleiben sie stehen - der Abgleich holt sie aber nicht wieder.
*/
const MAILS_GROUP: MenuGroup = {
item: { name: 'Mails', icon: MAIL_ICON },
views: MAIL_VIEWS,
children: [
{ name: 'Mails', label: 'Postfach', icon: MAIL_ICON },
{ name: 'Papierkorb', icon: TRASH_ICON },
],
};
/**
* Projekt Scanner. Wie bei den Kontakten klappen die Reiter darunter auf -
* "Scanner" selbst ist dabei das Dashboard. Die Einstellungen des Scanners
@@ -88,11 +104,13 @@ const CATEGORY_ENTRIES: (MenuItem | MenuGroup)[] = [
icon: 'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z',
},
{
name: 'Kalender',
// Der Terminkalender heisst im CRM "Interviews": genutzt wird er fuer
// Gespraechstermine, nicht als allgemeiner Kalender.
name: 'Interviews',
icon: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
},
CONTACTS_GROUP,
{ name: 'Mails', icon: MAIL_ICON },
MAILS_GROUP,
SCANNER_GROUP,
];
+2 -2
View File
@@ -884,7 +884,7 @@ export const Calendar: React.FC<CalendarProps> = ({
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]">
{/* Kalender */}
{/* Terminraster */}
<div className="space-y-3">
{isLoading && events.length === 0 ? (
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
@@ -940,7 +940,7 @@ export const Calendar: React.FC<CalendarProps> = ({
<div className="scrollbar-slim max-h-[70vh] flex-1 overflow-y-auto p-3">
{upcomingGroups.length === 0 ? (
<p className="rounded-xl border border-dashed border-ink-200 px-4 py-10 text-center text-xs font-semibold text-ink-400">
Nichts geplant. Lege links im Kalender einen Termin an.
Nichts geplant. Lege links im Terminraster einen Termin an.
</p>
) : (
<ul className="space-y-4">
+2 -2
View File
@@ -131,7 +131,7 @@ export const EventModal: React.FC<EventModalProps> = ({
subtitle={
event
? `Angelegt von ${event.created_by_name ?? 'unbekannt'}`
: 'Sichtbar für alle Benutzer im Teamkalender'
: 'Sichtbar für alle Benutzer unter Interviews'
}
onClose={onClose}
size="lg"
@@ -247,7 +247,7 @@ export const EventModal: React.FC<EventModalProps> = ({
Teilnehmer
</h3>
<p className="mb-3 text-xs text-ink-400">
Mehrfachauswahl möglich. Die Farbe des Termins im Kalender ist die des Erstellers.
Mehrfachauswahl möglich. Die Farbe des Termins ist die des Erstellers.
</p>
<div className="flex flex-wrap gap-2">
{users.map((user) => {
+2 -2
View File
@@ -18,7 +18,7 @@ interface UserDetailModalProps {
onOpenLead: (leadId: number) => void;
}
/** Detailfenster fuer einen im Kalender markierten Benutzer. */
/** Detailfenster fuer einen in einem Termin markierten Benutzer. */
export const UserDetailModal: React.FC<UserDetailModalProps> = ({
user,
leads,
@@ -78,7 +78,7 @@ export const UserDetailModal: React.FC<UserDetailModalProps> = ({
<p className="truncate text-sm text-ink-400">{user.email || 'Keine E-Mail hinterlegt'}</p>
<p className="mt-1 flex items-center gap-2 text-xs font-bold text-ink-400">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
Kalenderfarbe
Terminfarbe
</p>
</div>
</div>
+2 -2
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { Avatar, StatusBadge } from './leadUi';
import { Avatar, KindBadge } from './leadUi';
import { leadFullName, leadInitials, type Lead } from '../../types';
interface LeadGridProps {
@@ -77,7 +77,7 @@ export const LeadGrid: React.FC<LeadGridProps> = ({ leads, connectionCount, onEd
{lead.position && <p className="truncate text-xs font-semibold text-ink-500">{lead.position}</p>}
{lead.company && <p className="truncate text-xs text-ink-400">{lead.company}</p>}
<div className="mt-2">
<StatusBadge status={lead.status} />
<KindBadge kind={lead.kind} />
</div>
</div>
</div>
+4 -4
View File
@@ -1,8 +1,8 @@
import React from 'react';
import { Avatar, StatusBadge } from './leadUi';
import { Avatar, KindBadge } from './leadUi';
import { leadFullName, leadInitials, type Lead } from '../../types';
export type SortKey = 'name' | 'company' | 'status' | 'connections' | 'updated_at';
export type SortKey = 'name' | 'company' | 'kind' | 'connections' | 'updated_at';
export interface SortConfig {
key: SortKey;
@@ -21,7 +21,7 @@ interface LeadListProps {
const COLUMNS: { key: SortKey | null; label: string; className?: string }[] = [
{ key: 'name', label: 'Kontakt' },
{ key: 'company', label: 'Unternehmen' },
{ key: 'status', label: 'Status' },
{ key: 'kind', label: 'Art' },
{ key: null, label: 'Erreichbarkeit' },
{ key: 'connections', label: 'Verbindungen' },
{ key: null, label: 'Angelegt von' },
@@ -92,7 +92,7 @@ export const LeadList: React.FC<LeadListProps> = ({
{lead.city && <p className="truncate text-xs text-ink-400">{lead.city}</p>}
</td>
<td className="px-5 py-3">
<StatusBadge status={lead.status} />
<KindBadge kind={lead.kind} />
</td>
<td className="px-5 py-3">
<div className="space-y-0.5 text-xs">
+13 -10
View File
@@ -6,14 +6,14 @@ import { connectionsApi, leadsApi } from '../../lib/api';
import { useToast } from '../Toast';
import {
CONNECTION_TYPES,
LEAD_STATUSES,
LEAD_KINDS,
leadFullName,
leadInitials,
type AppUser,
type ConnectionType,
type Lead,
type LeadConnection,
type LeadStatus,
type LeadKind,
} from '../../types';
interface LeadModalProps {
@@ -46,7 +46,7 @@ interface FormState {
zip: string;
city: string;
country: string;
status: LeadStatus;
kind: LeadKind;
source: string;
notes: string;
avatar: string;
@@ -72,7 +72,7 @@ function toFormState(lead: Lead | null, fallbackOwnerId: number | null): FormSta
zip: lead?.zip ?? '',
city: lead?.city ?? '',
country: lead?.country ?? '',
status: lead?.status ?? 'Neu',
kind: lead?.kind ?? 'Recruiter',
source: lead?.source ?? '',
notes: lead?.notes ?? '',
avatar: lead?.avatar ?? '',
@@ -250,15 +250,18 @@ export const LeadModal: React.FC<LeadModalProps> = ({
</select>
</div>
<div>
<label className={labelClass}>Status</label>
<label className={labelClass} htmlFor="lead-kind">
Art
</label>
<select
value={form.status}
onChange={(e) => set('status', e.target.value as LeadStatus)}
id="lead-kind"
value={form.kind}
onChange={(e) => set('kind', e.target.value as LeadKind)}
className={inputClass}
>
{LEAD_STATUSES.map((status) => (
<option key={status} value={status}>
{status}
{LEAD_KINDS.map((kind) => (
<option key={kind} value={kind}>
{kind}
</option>
))}
</select>
+3 -3
View File
@@ -11,7 +11,7 @@ import {
type SimulationLinkDatum,
type SimulationNodeDatum,
} from 'd3-force';
import { STATUS_COLORS } from './leadUi';
import { KIND_COLORS } from './leadUi';
import { connectionsApi } from '../../lib/api';
import { useToast } from '../Toast';
import {
@@ -134,7 +134,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
leadId: lead.id,
label: leadFullName(lead),
sublabel: lead.company || lead.position || '',
color: STATUS_COLORS[lead.status],
color: KIND_COLORS[lead.kind],
degree: deg,
radius: 14 + Math.min(deg, 8) * 1.8,
avatar: lead.avatar,
@@ -875,7 +875,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
<div className="flex items-start gap-3">
<span
className="mt-1 h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: STATUS_COLORS[selectedLead.status] }}
style={{ backgroundColor: KIND_COLORS[selectedLead.kind] }}
/>
<div className="min-w-0">
<h3 className="truncate text-base text-ink-900">{leadFullName(selectedLead)}</h3>
+14 -15
View File
@@ -8,14 +8,14 @@ import { ConfirmDialog } from '../ui/Modal';
import { useToast } from '../Toast';
import { leadsApi } from '../../lib/api';
import {
LEAD_STATUSES,
LEAD_KINDS,
MAIL_LEAD_SOURCE,
leadFullName,
type AppUser,
type Lead,
type LeadConnection,
type LeadOriginFilter,
type LeadStatus,
type LeadKind,
type LeadViewMode,
} from '../../types';
@@ -68,7 +68,7 @@ export const Leads: React.FC<LeadsProps> = ({
const { addToast } = useToast();
const [viewMode, setViewMode] = useState<LeadViewMode>('grid');
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<LeadStatus | 'Alle'>('Alle');
const [kindFilter, setKindFilter] = useState<LeadKind | 'Alle'>('Alle');
const [originFilter, setOriginFilter] = useState<LeadOriginFilter>('Alle');
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: 'updated_at', direction: 'desc' });
@@ -112,7 +112,7 @@ export const Leads: React.FC<LeadsProps> = ({
.filter(Boolean);
const matches = leads.filter((lead) => {
if (statusFilter !== 'Alle' && lead.status !== statusFilter) return false;
if (kindFilter !== 'Alle' && lead.kind !== kindFilter) return false;
if (originFilter === 'Mail' && lead.source !== MAIL_LEAD_SOURCE) return false;
if (originFilter === 'Manuell' && lead.source === MAIL_LEAD_SOURCE) return false;
if (terms.length === 0) return true;
@@ -140,10 +140,8 @@ export const Leads: React.FC<LeadsProps> = ({
return leadFullName(a).localeCompare(leadFullName(b), 'de') * direction;
case 'company':
return (a.company ?? '').localeCompare(b.company ?? '', 'de') * direction;
case 'status':
return (
(LEAD_STATUSES.indexOf(a.status) - LEAD_STATUSES.indexOf(b.status)) * direction
);
case 'kind':
return (LEAD_KINDS.indexOf(a.kind) - LEAD_KINDS.indexOf(b.kind)) * direction;
case 'connections':
return (connectionCount(a.id) - connectionCount(b.id)) * direction;
case 'updated_at':
@@ -154,7 +152,7 @@ export const Leads: React.FC<LeadsProps> = ({
}
}
});
}, [leads, searchTerm, statusFilter, originFilter, sortConfig, connectionCounts]);
}, [leads, searchTerm, kindFilter, originFilter, sortConfig, connectionCounts]);
// Zaehler fuer den Herkunftsfilter - nur anzeigen, wenn es solche Kontakte gibt.
const mailLeadCount = useMemo(
@@ -231,14 +229,15 @@ export const Leads: React.FC<LeadsProps> = ({
</div>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as LeadStatus | 'Alle')}
value={kindFilter}
onChange={(event) => setKindFilter(event.target.value as LeadKind | 'Alle')}
aria-label="Nach Art des Kontakts filtern"
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
>
<option value="Alle">Alle Status</option>
{LEAD_STATUSES.map((status) => (
<option key={status} value={status}>
{status}
<option value="Alle">Alle Arten</option>
{LEAD_KINDS.map((kind) => (
<option key={kind} value={kind}>
{kind}
</option>
))}
</select>
+1 -1
View File
@@ -280,7 +280,7 @@ export const MailPoolLeads: React.FC<MailPoolLeadsProps> = ({
<p className="rounded-xl border border-ink-200 bg-surface px-4 py-3 text-xs leading-relaxed text-ink-500">
Diese Personen hat der Postfachabgleich in Mails gefunden. Sie stehen noch nicht in den Kontakten
und tauchen weder in Opportunities noch im Kalender auf.{' '}
und tauchen weder in Opportunities noch unter Interviews auf.{' '}
<span className="font-bold text-amber-700">Gold markierte Einträge</span> gibt es dort schon – bei
ihnen lassen sich abweichende Angaben einzeln in den vorhandenen Kontakt übernehmen.
</p>
+14 -20
View File
@@ -1,34 +1,28 @@
import React from 'react';
import type { LeadStatus } from '../../types';
import type { LeadKind } from '../../types';
/** Statusfarben: Gewonnen im CI-Gruen, der Rest bewusst zurueckhaltend. */
export const STATUS_STYLES: Record<LeadStatus, string> = {
Neu: 'bg-ink-100 text-ink-600 border-ink-200',
Kontaktiert: 'bg-sky-50 text-sky-700 border-sky-200',
Qualifiziert: 'bg-amber-50 text-amber-700 border-amber-200',
Angebot: 'bg-violet-50 text-violet-700 border-violet-200',
Gewonnen: 'bg-brand-50 text-brand-700 border-brand-200',
Verloren: 'bg-red-50 text-red-700 border-red-200',
/** Farben der Kontaktart: der Endkunde im CI-Gruen, der Rest daneben. */
export const KIND_STYLES: Record<LeadKind, string> = {
Recruiter: 'bg-sky-50 text-sky-700 border-sky-200',
Partner: 'bg-violet-50 text-violet-700 border-violet-200',
Endkunde: 'bg-brand-50 text-brand-700 border-brand-200',
};
/** Knotenfarbe im Netzwerkdiagramm - gleiche Logik wie das Badge. */
export const STATUS_COLORS: Record<LeadStatus, string> = {
Neu: '#9a9a9a',
Kontaktiert: '#0284c7',
Qualifiziert: '#d97706',
Angebot: '#7c3aed',
Gewonnen: '#6ba04a',
Verloren: '#dc2626',
export const KIND_COLORS: Record<LeadKind, string> = {
Recruiter: '#0284c7',
Partner: '#7c3aed',
Endkunde: '#6ba04a',
};
export const StatusBadge: React.FC<{ status: LeadStatus; className?: string }> = ({
status,
export const KindBadge: React.FC<{ kind: LeadKind; className?: string }> = ({
kind,
className = '',
}) => (
<span
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${STATUS_STYLES[status]} ${className}`}
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${KIND_STYLES[kind]} ${className}`}
>
{status}
{kind}
</span>
);
+47 -8
View File
@@ -21,9 +21,14 @@ import { mailApi, formatMailDateTime, type MailCandidate, type MailMessageDetail
interface MailDetailProps {
message: MailMessageDetail;
isLoading: boolean;
onLink: () => void;
/** Fehlt im Papierkorb: was geloescht ist, wird nicht zugeordnet. */
onLink?: () => void;
onReload: () => void;
onOpenLead: (leadId: number) => void;
/** Mail in den Papierkorb legen - nur im Postfach angeboten. */
onTrash?: () => void;
/** Mail aus dem Papierkorb zurueckholen. */
onRestore?: () => void;
}
const AddressRow: React.FC<{ label: string; value: string }> = ({ label, value }) => (
@@ -39,6 +44,8 @@ export const MailDetail: React.FC<MailDetailProps> = ({
onLink,
onReload,
onOpenLead,
onTrash,
onRestore,
}) => {
const { addToast } = useToast();
@@ -82,12 +89,31 @@ export const MailDetail: React.FC<MailDetailProps> = ({
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<button
onClick={onLink}
className="whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
>
Zuordnen
</button>
{onLink && (
<button
onClick={onLink}
className="whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
>
Zuordnen
</button>
)}
{onRestore && (
<button
onClick={onRestore}
className="whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
>
Wiederherstellen
</button>
)}
{onTrash && (
<button
onClick={onTrash}
title="Aus dem CRM entfernen. Im Postfach crm@perinco.de bleibt die Mail erhalten."
className="whitespace-nowrap rounded-xl border border-ink-200 px-4 py-2 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700"
>
Löschen
</button>
)}
{message.web_link && (
<a
href={message.web_link}
@@ -107,7 +133,10 @@ export const MailDetail: React.FC<MailDetailProps> = ({
{message.bcc.length > 0 && <AddressRow label="Bcc" value={formatAddresses(message.bcc, 6)} />}
</div>
{(message.has_attachments || message.is_deleted || message.importance === 'high') && (
{(message.has_attachments ||
message.is_deleted ||
message.crm_deleted_at !== null ||
message.importance === 'high') && (
<div className="mt-3 flex flex-wrap gap-2">
{message.has_attachments && (
<Badge className="border-ink-200 bg-ink-100 text-ink-600" title="Anhänge werden in Phase 1 nicht übernommen">
@@ -125,6 +154,16 @@ export const MailDetail: React.FC<MailDetailProps> = ({
Im Postfach gelöscht
</Badge>
)}
{message.crm_deleted_at !== null && (
<Badge
className="border-red-200 bg-red-50 text-red-700"
title={`Liegt im Papierkorb des CRM${
message.crm_deleted_by_name ? ` - gelöscht von ${message.crm_deleted_by_name}` : ''
}. Im Postfach ist die Mail unberührt.`}
>
Im Papierkorb
</Badge>
)}
</div>
)}
</section>
+121 -21
View File
@@ -3,6 +3,7 @@ import { MailList } from './MailList';
import { MailDetail } from './MailDetail';
import { MailLinkDialog } from './MailLinkDialog';
import { CandidateRow } from './CandidateRow';
import { ConfirmDialog } from '../ui/Modal';
import { Badge, emptyStateClass } from './mailUi';
import { useToast } from '../Toast';
import { useAuth } from '../../lib/auth';
@@ -24,6 +25,10 @@ import type { Lead, Opportunity } from '../../types';
* Zwei Bereiche: die Mailliste mit Detailansicht und die Sammelliste der offenen
* Kontakt-Vorschlaege. Letztere ist die eigentliche Arbeitsliste - dort wird
* entschieden, was Kontakt wird und was nicht.
*
* Derselbe Aufbau traegt den Papierkorb (`mode = 'papierkorb'`): Liste und
* Detailansicht sind dieselben, nur die Bedienelemente wechseln. Ein eigener
* Nachbau waere doppelte Pflege bei jeder Aenderung an der Mailansicht.
*/
interface MailsProps {
@@ -31,6 +36,8 @@ interface MailsProps {
opportunities: Opportunity[];
/** Kontakte im CRM neu laden - nach Uebernahme eines Vorschlags. */
reload: () => void;
/** 'papierkorb' zeigt die im CRM geloeschten Mails. */
mode?: 'postfach' | 'papierkorb';
}
type Tab = 'postfach' | 'vorschlaege';
@@ -117,7 +124,8 @@ const StatusBanner: React.FC<{ status: MailStatus; isAdmin: boolean; onOpenSetti
return null;
};
export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) => {
export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload, mode = 'postfach' }) => {
const isTrash = mode === 'papierkorb';
const { addToast } = useToast();
const { isAdmin } = useAuth();
const { goTo } = useNavigation();
@@ -141,6 +149,7 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
const [candidates, setCandidates] = useState<MailCandidate[]>([]);
const [isCandidatesLoading, setIsCandidatesLoading] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [isEmptyTrashOpen, setIsEmptyTrashOpen] = useState(false);
// Die Suche soll nicht bei jedem Tastendruck eine Abfrage aufloesen.
useEffect(() => {
@@ -165,6 +174,7 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
filter,
limit: PAGE_SIZE,
offset: nextOffset,
trash: isTrash,
});
setMessages(data.messages);
setTotal(data.total);
@@ -175,7 +185,7 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
setIsListLoading(false);
}
},
[debouncedSearch, filter, addToast]
[debouncedSearch, filter, isTrash, addToast]
);
const loadCandidates = useCallback(async () => {
@@ -259,6 +269,53 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
}
};
/*
* Nach dem Loeschen, Zurueckholen oder Leeren faellt die Mail aus der gerade
* gezeigten Liste. Die Auswahl wird deshalb aufgehoben und die Liste von vorn
* geladen - bewusst nicht ueber refreshAll(), das wuerde die Detailansicht der
* eben entfernten Mail nachladen und in einen 404 laufen.
*/
const refreshAfterTrashChange = useCallback(async () => {
setSelectedId(null);
setDetail(null);
await Promise.all([loadStatus(), loadCandidates(), loadMessages(0, true)]);
reload();
}, [loadStatus, loadCandidates, loadMessages, reload]);
const trashMessage = async (id: number) => {
try {
await mailApi.trashMessage(id);
addToast('Mail in den Papierkorb gelegt.', 'success');
await refreshAfterTrashChange();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
}
};
const restoreMessage = async (id: number) => {
try {
await mailApi.restoreMessage(id);
addToast('Mail wiederhergestellt.', 'success');
await refreshAfterTrashChange();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Wiederherstellen fehlgeschlagen.', 'error');
}
};
const emptyTrash = async () => {
setIsEmptyTrashOpen(false);
try {
const result = await mailApi.emptyTrash();
addToast(
result.purged === 1 ? '1 Mail endgültig gelöscht.' : `${result.purged} Mails endgültig gelöscht.`,
'success'
);
await refreshAfterTrashChange();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Leeren fehlgeschlagen.', 'error');
}
};
const openLead = (leadId: number) => goTo('Kontakte', { leadId });
const openCandidateCount = status?.counts.open_candidates ?? candidates.length;
@@ -282,6 +339,7 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
{/* Steuerleiste */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
{!isTrash && (
<div className="flex rounded-xl border border-ink-200 bg-surface p-1">
<button
onClick={() => setTab('postfach')}
@@ -311,6 +369,7 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
)}
</button>
</div>
)}
{tab === 'postfach' && (
<>
@@ -337,18 +396,20 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
</svg>
</div>
<select
value={filter}
onChange={(event) => setFilter(event.target.value as MailListFilter)}
title={FILTERS.find((entry) => entry.id === filter)?.hint}
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
>
{FILTERS.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</select>
{!isTrash && (
<select
value={filter}
onChange={(event) => setFilter(event.target.value as MailListFilter)}
title={FILTERS.find((entry) => entry.id === filter)?.hint}
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
>
{FILTERS.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</select>
)}
</>
)}
@@ -358,7 +419,16 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
Stand {formatMailDateTime(status.last_success_at)}
</span>
)}
{isAdmin && status?.configured && (
{isTrash && (
<button
onClick={() => setIsEmptyTrashOpen(true)}
disabled={messages.length === 0}
className="whitespace-nowrap rounded-xl border border-ink-200 px-4 py-2.5 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700 disabled:opacity-40"
>
Papierkorb leeren
</button>
)}
{!isTrash && isAdmin && status?.configured && (
<button
onClick={runSync}
disabled={isSyncing || status.running}
@@ -390,11 +460,27 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
</div>
) : messages.length === 0 ? (
<div className={emptyStateClass}>
{status?.configured
? debouncedSearch || filter !== 'alle'
? 'Keine Mails passen zur Auswahl.'
: 'Noch keine Mails abgeglichen.'
: 'Sobald die Anbindung eingerichtet ist, erscheinen hier die Mails aus crm@perinco.de.'}
{isTrash ? (
debouncedSearch ? (
'Keine Mails im Papierkorb passen zur Suche.'
) : (
<>
Der Papierkorb ist leer.
<p className="mx-auto mt-2 max-w-md text-xs font-normal text-ink-400">
Gelöschte Mails landen hier und lassen sich zurückholen. Im Postfach
crm@perinco.de bleiben sie in jedem Fall erhalten.
</p>
</>
)
) : status?.configured ? (
debouncedSearch || filter !== 'alle' ? (
'Keine Mails passen zur Auswahl.'
) : (
'Noch keine Mails abgeglichen.'
)
) : (
'Sobald die Anbindung eingerichtet ist, erscheinen hier die Mails aus crm@perinco.de.'
)}
</div>
) : (
<div className="grid gap-4 xl:grid-cols-[380px_1fr]">
@@ -429,9 +515,11 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
<MailDetail
message={detail}
isLoading={isDetailLoading}
onLink={() => setIsLinkOpen(true)}
onLink={isTrash ? undefined : () => setIsLinkOpen(true)}
onReload={refreshAll}
onOpenLead={openLead}
onTrash={isTrash ? undefined : () => trashMessage(detail.id)}
onRestore={isTrash ? () => restoreMessage(detail.id) : undefined}
/>
) : (
<div className={emptyStateClass}>Eine Mail auswählen.</div>
@@ -483,6 +571,18 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
</section>
)}
{isEmptyTrashOpen && (
<ConfirmDialog
title="Papierkorb endgültig leeren?"
message={`${total} ${
total === 1 ? 'Mail wird' : 'Mails werden'
} unwiderruflich aus dem CRM entfernt - samt ihrer Zuordnungen zu Kontakten und Opportunities. Im Postfach crm@perinco.de bleiben die Mails erhalten, das CRM holt sie aber nicht wieder herein.`}
confirmLabel="Endgültig löschen"
onConfirm={emptyTrash}
onCancel={() => setIsEmptyTrashOpen(false)}
/>
)}
{isLinkOpen && detail && (
<MailLinkDialog
message={detail}
+1 -1
View File
@@ -9,7 +9,7 @@ interface ColorPickerProps {
fallback: string;
}
/** Auswahl der persoenlichen Kalenderfarbe. */
/** Auswahl der persoenlichen Terminfarbe. */
export const ColorPicker: React.FC<ColorPickerProps> = ({ value, onChange, fallback }) => (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
+2 -2
View File
@@ -111,7 +111,7 @@ const AccountTab: React.FC = () => {
</dl>
<div className="mt-6 border-t border-ink-100 pt-5">
<h4 className="text-sm font-extrabold text-ink-900">Meine Kalenderfarbe</h4>
<h4 className="text-sm font-extrabold text-ink-900">Meine Terminfarbe</h4>
<p className="mb-3 text-xs text-ink-400">
Mit dieser Farbe erscheinen die von dir angelegten Termine im Teamkalender.
</p>
@@ -119,7 +119,7 @@ const AccountTab: React.FC = () => {
<ColorPicker
value={user?.color ?? null}
fallback={userColor(user ? { id: user.id, color: null } : null)}
onChange={(color) => saveProfile({ color }, 'Kalenderfarbe gespeichert.')}
onChange={(color) => saveProfile({ color }, 'Terminfarbe gespeichert.')}
/>
</div>
</div>
+1 -1
View File
@@ -330,7 +330,7 @@ export const UserManagement: React.FC = () => {
/>
</div>
<div>
<label className={labelClass}>Kalenderfarbe</label>
<label className={labelClass}>Terminfarbe</label>
<ColorPicker
value={form.color}
fallback={userColor(editingUser ? { id: editingUser.id, color: null } : { id: 0 })}
+2 -2
View File
@@ -21,8 +21,8 @@ interface UserAvatarProps {
}
/**
* Foto des Benutzers, sonst die Initiale auf seiner Kalenderfarbe. Damit sehen
* Benutzer ueberall gleich aus - Sidebar, Kalender, Nutzerverwaltung.
* Foto des Benutzers, sonst die Initiale auf seiner Terminfarbe. Damit sehen
* Benutzer ueberall gleich aus - Sidebar, Interviews, Nutzerverwaltung.
*/
export const UserAvatar: React.FC<UserAvatarProps> = ({
user,
+3 -3
View File
@@ -85,7 +85,7 @@ export const authApi = {
}),
me: () => request<{ user: AppUser }>('/auth/me'),
/**
* Eigene Kalenderfarbe und eigenes Foto setzen - ohne Adminrechte.
* Eigene Terminfarbe und eigenes Foto setzen - ohne Adminrechte.
* null setzt zurueck: Farbe auf die Palettenfarbe, Foto auf die Initiale.
*/
updateMe: (input: { color?: string | null; avatar?: string | null }) =>
@@ -113,7 +113,7 @@ export interface UserInput {
export const usersApi = {
list: () => request<AppUser[]>('/users'),
/**
* Aktive Benutzer - fuer jeden Angemeldeten abrufbar. Der Kalender braucht
* Aktive Benutzer - fuer jeden Angemeldeten abrufbar. Die Terminansicht braucht
* die Liste fuer @-Mentions, Teilnehmer und Farben, auch fuer Rolle "user".
*/
directory: () => request<AppUser[]>('/users/directory'),
@@ -197,7 +197,7 @@ export const opportunitiesApi = {
remove: (id: number) => request<{ ok: true }>(`/opportunities/${id}`, { method: 'DELETE' }),
};
// ---------- Kalender ----------
// ---------- Interviews (Termine) ----------
export interface EventInput {
title: string;
+29 -1
View File
@@ -23,7 +23,11 @@ export interface MailMessageSummary {
received_at: string;
body_preview: string | null;
has_attachments: boolean;
/** Im Postfach geloescht - nicht zu verwechseln mit `crm_deleted_at`. */
is_deleted: boolean;
/** Gesetzt = liegt im Papierkorb des CRM (UTC). */
crm_deleted_at: string | null;
crm_deleted_by_name: string | null;
web_link: string | null;
link_count: number;
open_candidates: number;
@@ -115,6 +119,8 @@ export interface MailStatus {
last_candidates: number;
counts: {
messages: number;
/** Mails im Papierkorb des CRM. */
trash: number;
open_candidates: number;
adopted_candidates: number;
links: number;
@@ -133,6 +139,8 @@ export interface MailSyncResult {
deletedMessages?: number;
newCandidates?: number;
adoptedContacts?: number;
/** Im CRM geloeschte Mails, die der Abgleich deshalb nicht angefasst hat. */
skippedTrashed?: number;
hasMore?: boolean;
error?: string;
}
@@ -171,12 +179,22 @@ export type MailListFilter = 'alle' | 'unlinked' | 'linked' | 'candidates';
export const mailApi = {
status: () => apiRequest<MailStatus>('/mail/status'),
messages: (params: { search?: string; filter?: MailListFilter; limit?: number; offset?: number } = {}) => {
messages: (
params: {
search?: string;
filter?: MailListFilter;
limit?: number;
offset?: number;
/** true = nur der Papierkorb, sonst alles ausser dem Papierkorb. */
trash?: boolean;
} = {}
) => {
const query = new URLSearchParams();
if (params.search) query.set('search', params.search);
if (params.filter && params.filter !== 'alle') query.set('filter', params.filter);
if (params.limit) query.set('limit', String(params.limit));
if (params.offset) query.set('offset', String(params.offset));
if (params.trash) query.set('trash', 'true');
const suffix = query.toString() ? `?${query}` : '';
return apiRequest<{ total: number; limit: number; offset: number; messages: MailMessageSummary[] }>(
`/mail/messages${suffix}`
@@ -193,6 +211,16 @@ export const mailApi = {
unlink: (linkId: number) => apiRequest<{ ok: true }>(`/mail/links/${linkId}`, { method: 'DELETE' }),
/**
* Papierkorb. Geloescht wird ausschliesslich im CRM - das Postfach
* crm@perinco.de bleibt unberuehrt, der Abgleich holt geloeschte Mails aber
* nicht wieder herein.
*/
trashMessage: (id: number) => apiRequest<{ ok: true }>(`/mail/messages/${id}`, { method: 'DELETE' }),
restoreMessage: (id: number) =>
apiRequest<{ ok: true }>(`/mail/messages/${id}/restore`, { method: 'POST' }),
emptyTrash: () => apiRequest<{ ok: true; purged: number }>('/mail/trash/empty', { method: 'POST' }),
candidates: (params: { status?: CandidateStatus | 'alle'; limit?: number } = {}) => {
const query = new URLSearchParams();
query.set('status', params.status ?? 'offen');
+22 -15
View File
@@ -1,9 +1,10 @@
export type ViewState =
| 'Opportunities'
| 'Kalender'
| 'Interviews'
| 'Kontakte'
| 'Aus Mails'
| 'Mails'
| 'Papierkorb'
| 'Scanner'
| 'Unpassend'
| 'Suchagenten'
@@ -37,6 +38,13 @@ export const SCANNER_VIEWS: ViewState[] = ['Scanner', 'Unpassend', 'Suchagenten'
*/
export const CONTACT_VIEWS: ViewState[] = ['Kontakte', 'Aus Mails'];
/**
* Mailbereich. "Mails" ist das gespiegelte Postfach, "Papierkorb" enthaelt die
* im CRM geloeschten Mails. Geloescht heisst nur: das CRM listet sie nicht mehr -
* im Postfach crm@perinco.de bleiben sie unberuehrt.
*/
export const MAIL_VIEWS: ViewState[] = ['Mails', 'Papierkorb'];
export type Role = 'admin' | 'user';
export interface AppUser {
@@ -45,7 +53,7 @@ export interface AppUser {
email: string | null;
name: string;
role: Role;
/** Persoenliche Kalenderfarbe als Hex; null = automatisch aus der Palette. */
/** Persoenliche Terminfarbe als Hex; null = automatisch aus der Palette. */
color?: string | null;
/** Benutzerfoto als Data-URL oder externe URL; null = Initiale anzeigen. */
avatar?: string | null;
@@ -55,7 +63,7 @@ export interface AppUser {
}
/**
* Farbpalette fuer Benutzer im Kalender. Bewusst kontraststark untereinander
* Farbpalette fuer Benutzer in den Terminen. Bewusst kontraststark untereinander
* und dunkel genug fuer weisse Schrift; das CI-Gruen steht an erster Stelle.
*/
export const USER_COLORS = [
@@ -78,16 +86,15 @@ export function userColor(user?: { id: number; color?: string | null } | null):
return USER_COLORS[Math.abs(user.id) % USER_COLORS.length];
}
export const LEAD_STATUSES = [
'Neu',
'Kontaktiert',
'Qualifiziert',
'Angebot',
'Gewonnen',
'Verloren',
] as const;
/**
* Art eines Kontakts - in der Oberflaeche "Art". Frueher stand hier ein
* Vertriebsstatus (Neu .. Verloren); der sagte nichts darueber, mit wem man es
* zu tun hat. Bestehende Kontakte wurden bei der Umstellung auf 'Recruiter'
* gesetzt (siehe migrateLeadKind() im Backend).
*/
export const LEAD_KINDS = ['Recruiter', 'Partner', 'Endkunde'] as const;
export type LeadStatus = (typeof LEAD_STATUSES)[number];
export type LeadKind = (typeof LEAD_KINDS)[number];
/**
* Herkunft von Kontakten, die aus dem Postfach entstanden sind. Steht in
@@ -115,12 +122,12 @@ export interface Lead {
zip: string | null;
city: string | null;
country: string | null;
status: LeadStatus;
kind: LeadKind;
source: string | null;
/**
* true = Eintrag im Vorbereich "Aus Mails". Solche Kontakte stammen aus dem
* Postfachabgleich und erscheinen nicht in der Kontaktliste, in Opportunities
* oder im Kalender - erst die Uebernahme macht daraus einen Kontakt.
* oder in Terminen - erst die Uebernahme macht daraus einen Kontakt.
*/
mail_pool: boolean;
/**
@@ -262,7 +269,7 @@ export function contactFullName(contact: Pick<OpportunityContact, 'first_name' |
return `${contact.first_name} ${contact.last_name}`.trim();
}
// ---------- Kalender ----------
// ---------- Interviews (Termine) ----------
export interface EventParticipant {
id: number;