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
+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');