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:
co-authored by
Claude Opus 5
parent
025390e18a
commit
8741a9ae0c
+14
-5
@@ -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 />}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user