Neuer Menüpunkt Scanner mit Dashboard und Suchagenten, Zeitplan und API-Schlüssel unter Einstellungen -> Scanner. - Quellen: Agentur für Arbeit (API), StepStone, karriere.at, freelancermap (HTML) sowie Adzuna und Jooble (kostenlose Schlüssel) - Quote = gefundene Schlagwörter, Titel zählt doppelt; keine KI - Abgleich mit icon_companies, bekannte Firmen oben mit Abzeichen - Zeitplan Vorgabe stündlich Mo-Fr 06-20 Uhr, einstellbar - Sperre gegen parallele Läufe wie im perinco-Scanner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
537 lines
20 KiB
TypeScript
537 lines
20 KiB
TypeScript
import type {
|
|
AppUser,
|
|
Company,
|
|
Connection,
|
|
Contact,
|
|
DirectoryUser,
|
|
Employment,
|
|
HistoryEntry,
|
|
Interview,
|
|
Lead,
|
|
LeadTodo,
|
|
PerincoContact,
|
|
Project,
|
|
Tender,
|
|
} from '../types';
|
|
|
|
const TOKEN_KEY = 'icon_crm_token';
|
|
|
|
export function getToken(): string | null {
|
|
return localStorage.getItem(TOKEN_KEY);
|
|
}
|
|
export function setToken(token: string) {
|
|
localStorage.setItem(TOKEN_KEY, token);
|
|
}
|
|
export function clearToken() {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
constructor(message: string, status: number) {
|
|
super(message);
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
/** Wird bei 401 gefeuert, damit die Anwendung zurueck auf den Login springt. */
|
|
let onUnauthorized: (() => void) | null = null;
|
|
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
|
onUnauthorized = handler;
|
|
}
|
|
|
|
export async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const headers = new Headers(options.headers);
|
|
if (options.body && !headers.has('Content-Type') && !(options.body instanceof FormData)) {
|
|
headers.set('Content-Type', 'application/json');
|
|
}
|
|
const token = getToken();
|
|
if (token) headers.set('Authorization', `Bearer ${token}`);
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`/api${path}`, { ...options, headers });
|
|
} catch {
|
|
throw new ApiError('Server nicht erreichbar. Bitte Verbindung prüfen.', 0);
|
|
}
|
|
|
|
if (response.status === 401) {
|
|
clearToken();
|
|
onUnauthorized?.();
|
|
}
|
|
|
|
const isJson = response.headers.get('content-type')?.includes('application/json');
|
|
const payload = isJson ? await response.json().catch(() => null) : null;
|
|
|
|
if (!response.ok) {
|
|
const message =
|
|
(payload && typeof payload === 'object' && 'error' in payload && String(payload.error)) ||
|
|
`Anfrage fehlgeschlagen (HTTP ${response.status}).`;
|
|
throw new ApiError(message, response.status);
|
|
}
|
|
|
|
return payload as T;
|
|
}
|
|
|
|
const get = <T>(path: string) => apiRequest<T>(path);
|
|
const post = <T>(path: string, body?: unknown) =>
|
|
apiRequest<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) });
|
|
const patch = <T>(path: string, body: unknown) =>
|
|
apiRequest<T>(path, { method: 'PATCH', body: JSON.stringify(body) });
|
|
const del = <T>(path: string) => apiRequest<T>(path, { method: 'DELETE' });
|
|
|
|
/** Haengt nur gesetzte Werte als Abfrageparameter an. */
|
|
function query(params: Record<string, unknown>): string {
|
|
const search = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value === undefined || value === null || value === '') continue;
|
|
search.set(key, String(value));
|
|
}
|
|
const text = search.toString();
|
|
return text ? `?${text}` : '';
|
|
}
|
|
|
|
export const authApi = {
|
|
login: (identifier: string, password: string) =>
|
|
post<{ token: string; user: AppUser }>('/auth/login', { identifier, password }),
|
|
me: () => get<{ user: AppUser }>('/auth/me'),
|
|
updateMe: (patchBody: Partial<Pick<AppUser, 'color' | 'avatar' | 'theme' | 'theme_mode'>>) =>
|
|
patch<{ user: AppUser }>('/auth/me', patchBody),
|
|
changePassword: (current_password: string, new_password: string) =>
|
|
post<{ ok: true }>('/auth/change-password', { current_password, new_password }),
|
|
};
|
|
|
|
export const usersApi = {
|
|
directory: () => get<{ users: DirectoryUser[] }>('/users/directory'),
|
|
list: () => get<{ users: AppUser[] }>('/users'),
|
|
create: (body: Record<string, unknown>) => post<{ user: AppUser }>('/users', body),
|
|
update: (id: number, body: Record<string, unknown>) => patch<{ user: AppUser }>(`/users/${id}`, body),
|
|
remove: (id: number) => del<{ ok: true }>(`/users/${id}`),
|
|
};
|
|
|
|
export interface CompanyDetail {
|
|
company: Company;
|
|
contacts: Array<Pick<Contact, 'id' | 'first_name' | 'last_name' | 'position' | 'email' | 'phone' | 'avatar' | 'kind' | 'status'>>;
|
|
former: Array<{
|
|
employment_id: number;
|
|
id: number;
|
|
first_name: string | null;
|
|
last_name: string;
|
|
avatar: string | null;
|
|
position: string | null;
|
|
started_on: string | null;
|
|
ended_on: string | null;
|
|
now_company: string | null;
|
|
now_company_id: number | null;
|
|
}>;
|
|
interviews: Interview[];
|
|
history: HistoryEntry[];
|
|
mentioned: HistoryEntry[];
|
|
}
|
|
|
|
export const companiesApi = {
|
|
list: (params: Record<string, unknown> = {}) =>
|
|
get<{ companies: Company[] }>(`/companies${query(params)}`),
|
|
options: () => get<{ industries: string[]; groups: string[] }>('/companies/options'),
|
|
detail: (id: number) => get<CompanyDetail>(`/companies/${id}`),
|
|
create: (body: Record<string, unknown>) => post<{ company: Company }>('/companies', body),
|
|
update: (id: number, body: Record<string, unknown>) =>
|
|
patch<{ company: Company }>(`/companies/${id}`, body),
|
|
remove: (id: number) => del<{ ok: true }>(`/companies/${id}`),
|
|
addInterview: (id: number, body: Record<string, unknown>) =>
|
|
post<{ id: number }>(`/companies/${id}/interviews`, body),
|
|
};
|
|
|
|
export interface ContactDetail {
|
|
contact: Contact;
|
|
employments: Employment[];
|
|
interviews: Interview[];
|
|
history: HistoryEntry[];
|
|
mentioned: HistoryEntry[];
|
|
}
|
|
|
|
export const contactsApi = {
|
|
list: (params: Record<string, unknown> = {}) =>
|
|
get<{ contacts: Contact[] }>(`/contacts${query(params)}`),
|
|
detail: (id: number) => get<ContactDetail>(`/contacts/${id}`),
|
|
create: (body: Record<string, unknown>) => post<{ contact: Contact }>('/contacts', body),
|
|
update: (id: number, body: Record<string, unknown>) =>
|
|
patch<{ contact: Contact }>(`/contacts/${id}`, body),
|
|
remove: (id: number) => del<{ ok: true }>(`/contacts/${id}`),
|
|
addInterview: (id: number, body: Record<string, unknown>) =>
|
|
post<{ id: number }>(`/contacts/${id}/interviews`, body),
|
|
/*
|
|
* Werdegang von Hand pflegen. Die Stationen entstehen zwar beim Setzen eines
|
|
* Arbeitgebers und beim CV-Import von selbst - aber kein Lebenslauf wird
|
|
* vollständig erkannt, und ein falscher Firmenname ließ sich sonst nicht mehr
|
|
* geradeziehen.
|
|
*
|
|
* `company` (Name) oder `company_id` - ein unbekannter Name legt das
|
|
* Unternehmen an, wie im CV-Import auch.
|
|
*/
|
|
addEmployment: (id: number, body: Record<string, unknown>) =>
|
|
post<{ id: number }>(`/contacts/${id}/employments`, body),
|
|
updateEmployment: (employmentId: number, body: Record<string, unknown>) =>
|
|
patch<{ ok: true }>(`/contacts/employments/${employmentId}`, body),
|
|
removeEmployment: (employmentId: number) =>
|
|
del<{ ok: true }>(`/contacts/employments/${employmentId}`),
|
|
lastEmployers: () =>
|
|
get<{
|
|
entries: Array<{
|
|
contact_id: number;
|
|
first_name: string | null;
|
|
last_name: string;
|
|
avatar: string | null;
|
|
company_id: number;
|
|
company: string;
|
|
industry: string | null;
|
|
position: string | null;
|
|
ended_on: string | null;
|
|
}>;
|
|
}>('/contacts/last-employers'),
|
|
linkedInPhoto: (url: string) =>
|
|
post<{ avatar: string; bytes: number }>('/contacts/linkedin-photo', { url }),
|
|
};
|
|
|
|
export const tendersApi = {
|
|
list: (params: Record<string, unknown> = {}) => get<{ tenders: Tender[] }>(`/tenders${query(params)}`),
|
|
detail: (id: number) =>
|
|
get<{ tender: Tender; contacts: Contact[]; history: HistoryEntry[] }>(`/tenders/${id}`),
|
|
create: (body: Record<string, unknown>) => post<{ tender: Tender }>('/tenders', body),
|
|
update: (id: number, body: Record<string, unknown>) => patch<{ tender: Tender }>(`/tenders/${id}`, body),
|
|
remove: (id: number) => del<{ ok: true }>(`/tenders/${id}`),
|
|
};
|
|
|
|
export const projectsApi = {
|
|
list: (params: Record<string, unknown> = {}) =>
|
|
get<{ projects: Project[] }>(`/projects${query(params)}`),
|
|
create: (body: Record<string, unknown>) => post<{ project: Project }>('/projects', body),
|
|
update: (id: number, body: Record<string, unknown>) =>
|
|
patch<{ project: Project }>(`/projects/${id}`, body),
|
|
remove: (id: number) => del<{ ok: true }>(`/projects/${id}`),
|
|
};
|
|
|
|
export const leadsApi = {
|
|
list: (archived = false) => get<{ leads: Lead[] }>(`/leads${query({ archived: archived ? 1 : 0 })}`),
|
|
detail: (id: number) =>
|
|
get<{ lead: Lead; todos: LeadTodo[]; history: HistoryEntry[] }>(`/leads/${id}`),
|
|
create: (body: Record<string, unknown>) => post<{ lead: Lead }>('/leads', body),
|
|
update: (id: number, body: Record<string, unknown>) => patch<{ lead: Lead }>(`/leads/${id}`, body),
|
|
remove: (id: number) => del<{ ok: true }>(`/leads/${id}`),
|
|
reminders: () =>
|
|
get<{ due: Lead[]; stale: Lead[]; stale_days: number }>('/leads/reminders'),
|
|
acknowledge: (id: number, snooze_days?: number) =>
|
|
post<{ ok: true }>(`/leads/${id}/acknowledge`, { snooze_days }),
|
|
addTodo: (id: number, text: string, due_on?: string | null) =>
|
|
post<{ id: number }>(`/leads/${id}/todos`, { text, due_on }),
|
|
updateTodo: (todoId: number, body: Record<string, unknown>) =>
|
|
patch<{ ok: true }>(`/leads/todos/${todoId}`, body),
|
|
removeTodo: (todoId: number) => del<{ ok: true }>(`/leads/todos/${todoId}`),
|
|
};
|
|
|
|
/** Vorschlag der KI zu einem Lebenslauf. */
|
|
export interface CvSuggestion {
|
|
first_name: string | null;
|
|
last_name: string | null;
|
|
position: string | null;
|
|
email: string | null;
|
|
phone: string | null;
|
|
linkedin: string | null;
|
|
company: string | null;
|
|
tags: string[];
|
|
bio: string | null;
|
|
rate_onsite: number | null;
|
|
rate_remote: number | null;
|
|
employments: Array<{ company: string; position: string | null; from: string | null; to: string | null }>;
|
|
confidence: number | null;
|
|
}
|
|
|
|
export interface CvImport {
|
|
id: number;
|
|
filename: string;
|
|
original_name: string;
|
|
mime: string | null;
|
|
size_bytes: number;
|
|
status: 'eingang' | 'screening' | 'auswahl' | 'verworfen';
|
|
extracted: CvSuggestion | null;
|
|
/**
|
|
* Traegt der Lebenslauf ein Bewerbungsfoto? Das Bild selbst steht nicht in
|
|
* der Liste - es wird bei Bedarf einzeln geholt (`cvsApi.photo`) und erst im
|
|
* Browser verkleinert (siehe lib/imageCompress.ts).
|
|
*/
|
|
has_photo: boolean;
|
|
error: string | null;
|
|
contact_id: number | null;
|
|
contact_name: string | null;
|
|
uploaded_by_name: string | null;
|
|
created_at: string;
|
|
processed_at: string | null;
|
|
}
|
|
|
|
export const cvsApi = {
|
|
list: () =>
|
|
get<{ imports: CvImport[]; ai: { configured: boolean; model: string } }>('/cvs'),
|
|
/** Mehrere Dateien auf einmal - der Browser setzt die multipart-Grenze selbst. */
|
|
upload: (files: File[]) => {
|
|
const body = new FormData();
|
|
for (const file of files) body.append('files', file);
|
|
return apiRequest<{ ids: number[]; count: number }>('/cvs', { method: 'POST', body });
|
|
},
|
|
analyze: (id: number) =>
|
|
post<{ extracted: CvSuggestion; photo: string | null }>(`/cvs/${id}/analyze`),
|
|
/** Das gefundene Foto, unveraendert - nur fuer das Formular. */
|
|
photo: (id: number) => get<{ photo: string | null }>(`/cvs/${id}/photo`),
|
|
accept: (id: number, overrides: Record<string, unknown> = {}) =>
|
|
post<{ contact_id: number }>(`/cvs/${id}/accept`, overrides),
|
|
reject: (id: number) => post<{ ok: true }>(`/cvs/${id}/reject`),
|
|
remove: (id: number) => del<{ ok: true }>(`/cvs/${id}`),
|
|
};
|
|
|
|
export type AiTask = 'write' | 'improve' | 'shorten' | 'bullets' | 'profile';
|
|
|
|
/**
|
|
* Zustand der KI-Einrichtung. Der Schlüssel steht hier nur verkürzt
|
|
* ("sk-proj-…a1b2") - vollständig verlässt er den Server nie.
|
|
*/
|
|
export interface AiConfig {
|
|
configured: boolean;
|
|
model: string;
|
|
defaultModel: string;
|
|
/** 'db' = in den Einstellungen gepflegt, 'env' = aus server/.env, 'none' = keiner */
|
|
source: 'db' | 'env' | 'none';
|
|
masked: string | null;
|
|
envAvailable: boolean;
|
|
}
|
|
|
|
export const aiApi = {
|
|
status: () => get<{ configured: boolean; model: string; tasks: AiTask[] }>('/ai/status'),
|
|
text: (task: AiTask, current: string, prompt?: string) =>
|
|
post<{ text: string; model: string }>('/ai/text', { task, current, prompt }),
|
|
config: () => get<AiConfig>('/ai/config'),
|
|
models: () => get<{ models: { id: string; created: number }[] }>('/ai/models'),
|
|
saveConfig: (changes: { key?: string; model?: string }) => patch<AiConfig>('/ai/config', changes),
|
|
test: () => post<{ ok: true; model: string; ms: number; answer: string }>('/ai/test'),
|
|
};
|
|
|
|
export const perincoApi = {
|
|
status: () => get<{ configured: boolean; url: string | null }>('/perinco/status'),
|
|
contacts: () => get<{ contacts: PerincoContact[]; cached: boolean }>('/perinco/contacts'),
|
|
/**
|
|
* Abgleich eines doppelt gefuehrten Kontakts.
|
|
*
|
|
* `local` sind die Felder, die hier gelten sollen, `remote` die, die drueben
|
|
* gelten sollen - beide in der Sprache der Bruecke (`notes`, `source`).
|
|
*/
|
|
merge: (
|
|
remoteId: string,
|
|
body: {
|
|
contact_id: number;
|
|
local: Record<string, unknown>;
|
|
remote: Record<string, unknown>;
|
|
}
|
|
) =>
|
|
post<{ ok: true; local_changed: string[]; remote_changed: string[] }>(
|
|
`/perinco/contacts/${remoteId}/merge`,
|
|
body
|
|
),
|
|
};
|
|
|
|
export const historyApi = {
|
|
list: (params: Record<string, unknown> = {}) =>
|
|
get<{ entries: HistoryEntry[] }>(`/history${query(params)}`),
|
|
add: (entity_type: string, entity_id: number | null, text: string) =>
|
|
post<{ entry: HistoryEntry }>('/history', { entity_type, entity_id, text }),
|
|
remove: (id: number) => del<{ ok: true }>(`/history/${id}`),
|
|
};
|
|
|
|
export interface SearchGroup {
|
|
type: 'contact' | 'company' | 'tender' | 'project' | 'lead' | 'history';
|
|
label: string;
|
|
items: Array<Record<string, unknown> & { id: number; title?: string }>;
|
|
}
|
|
|
|
export interface MentionCandidate {
|
|
kind: 'user' | 'contact' | 'company';
|
|
id: number;
|
|
label: string;
|
|
hint: string | null;
|
|
avatar: string | null;
|
|
color?: string | null;
|
|
}
|
|
|
|
export interface AiAnswer {
|
|
answer: string;
|
|
refs: Array<{ type: 'contact' | 'company' | 'tender' | 'project' | 'lead'; id: number; label: string }>;
|
|
model: string;
|
|
truncated: boolean;
|
|
}
|
|
|
|
export const searchApi = {
|
|
search: (q: string) => get<{ query: string; groups: SearchGroup[]; total: number }>(`/search${query({ q })}`),
|
|
mentions: (q: string) => get<{ candidates: MentionCandidate[] }>(`/search/mentions${query({ q })}`),
|
|
ask: (question: string) => post<AiAnswer>('/search/ai', { question }),
|
|
};
|
|
|
|
export interface OptionValue {
|
|
id: number;
|
|
field: string;
|
|
value: string;
|
|
position: number;
|
|
/** Wie oft der Wert im Bestand verwendet wird. */
|
|
used: number;
|
|
}
|
|
|
|
export interface OptionField {
|
|
field: string;
|
|
label: string;
|
|
values: OptionValue[];
|
|
}
|
|
|
|
export const optionsApi = {
|
|
list: () => get<{ fields: OptionField[] }>('/options'),
|
|
create: (field: string, value: string) => post<{ id: number }>('/options', { field, value }),
|
|
rename: (id: number, value: string) => patch<{ ok: true; renamed: number }>(`/options/${id}`, { value }),
|
|
remove: (id: number) => del<{ ok: true }>(`/options/${id}`),
|
|
};
|
|
|
|
export interface NetworkData {
|
|
companies: Array<Company & { id: number }>;
|
|
contacts: Array<{
|
|
id: number;
|
|
name: string;
|
|
position: string | null;
|
|
avatar: string | null;
|
|
kind: string;
|
|
status: string;
|
|
company_id: number | null;
|
|
}>;
|
|
edges: Array<Record<string, unknown> & { type: 'employment' | 'former' | 'contact' | 'group' }>;
|
|
groups: Array<{ name: string; count: number }>;
|
|
}
|
|
|
|
export const networkApi = {
|
|
load: (params: Record<string, unknown> = {}) => get<NetworkData>(`/network${query(params)}`),
|
|
};
|
|
|
|
export const connectionsApi = {
|
|
list: () => get<{ connections: Connection[] }>('/connections'),
|
|
create: (contact_a: number, contact_b: number, kind?: string) =>
|
|
post<Connection>('/connections', { contact_a, contact_b, kind }),
|
|
update: (id: number, kind: string) => patch<{ ok: true }>(`/connections/${id}`, { kind }),
|
|
remove: (id: number) => del<{ ok: true }>(`/connections/${id}`),
|
|
};
|
|
|
|
export interface DashboardData {
|
|
counts: Record<string, number>;
|
|
contacts_by_status: Array<{ status: string; count: number }>;
|
|
companies_by_industry: Array<{ industry: string; count: number }>;
|
|
tenders_by_stage: Array<{ stage: string; count: number }>;
|
|
recent: HistoryEntry[];
|
|
}
|
|
|
|
export const dashboardApi = {
|
|
load: () => get<DashboardData>('/dashboard'),
|
|
};
|
|
|
|
/* ---------------------------------------------------------------- Scanner */
|
|
|
|
export type ScannerSourceId = 'ba' | 'stepstone' | 'karriere' | 'adzuna' | 'jooble' | 'freelancermap';
|
|
export type ScannerMatchStatus = 'neu' | 'gesehen' | 'in_bearbeitung' | 'verworfen';
|
|
|
|
export interface ScannerAgent {
|
|
id: number;
|
|
name: string;
|
|
keywords: string[];
|
|
sources: ScannerSourceId[];
|
|
excludes: string[];
|
|
is_active: boolean;
|
|
matches: number;
|
|
}
|
|
|
|
export interface ScannerSchedule {
|
|
active: boolean;
|
|
/** ISO-Wochentage, 1 = Montag */
|
|
days: number[];
|
|
from: string;
|
|
to: string;
|
|
/** Minuten */
|
|
interval: number;
|
|
/** höchstes Alter einer Anzeige in Tagen */
|
|
maxAge: number;
|
|
}
|
|
|
|
export interface ScannerSource {
|
|
id: ScannerSourceId;
|
|
label: string;
|
|
countries: string;
|
|
ready: boolean;
|
|
needsKey: 'adzuna' | 'jooble' | null;
|
|
last: { ok: boolean; count: number; errors: string[] } | null;
|
|
}
|
|
|
|
export interface ScannerOverview {
|
|
stats: { agents: number; jobs: number; matches: number; fresh: number; known: number };
|
|
sources: ScannerSource[];
|
|
schedule: ScannerSchedule;
|
|
lastScanAt: string | null;
|
|
lastRun: {
|
|
id: number;
|
|
trigger: 'auto' | 'manual';
|
|
started_at: string;
|
|
finished_at: string;
|
|
jobs_found: number;
|
|
matches_new: number;
|
|
} | null;
|
|
status: { running: boolean; since: string | null };
|
|
}
|
|
|
|
export interface ScannerMatch {
|
|
id: number;
|
|
score: number;
|
|
hits: string[];
|
|
status: ScannerMatchStatus;
|
|
created_at: string;
|
|
agent_id: number;
|
|
agent_name: string;
|
|
job_id: number;
|
|
source: ScannerSourceId;
|
|
title: string;
|
|
company: string | null;
|
|
location: string | null;
|
|
country: string | null;
|
|
employment_type: string | null;
|
|
url: string;
|
|
description: string | null;
|
|
published_at: string | null;
|
|
company_id: number | null;
|
|
company_name: string | null;
|
|
is_customer: boolean | null;
|
|
known: boolean;
|
|
}
|
|
|
|
export interface ScannerSecrets {
|
|
adzunaId: string | null;
|
|
adzunaKey: string | null;
|
|
joobleKey: string | null;
|
|
}
|
|
|
|
export type ScannerAgentInput = Pick<ScannerAgent, 'name' | 'keywords' | 'sources' | 'excludes' | 'is_active'>;
|
|
|
|
const put = <T>(path: string, body: unknown) =>
|
|
apiRequest<T>(path, { method: 'PUT', body: JSON.stringify(body) });
|
|
|
|
export const scannerApi = {
|
|
overview: () => get<ScannerOverview>('/scanner/overview'),
|
|
agents: () => get<{ agents: ScannerAgent[] }>('/scanner/agents'),
|
|
createAgent: (agent: ScannerAgentInput) => post<{ agent: ScannerAgent }>('/scanner/agents', agent),
|
|
updateAgent: (id: number, agent: ScannerAgentInput) => put<{ agent: ScannerAgent }>(`/scanner/agents/${id}`, agent),
|
|
removeAgent: (id: number) => del<{ ok: true }>(`/scanner/agents/${id}`),
|
|
matches: (params: Record<string, unknown> = {}) =>
|
|
get<{ total: number; matches: ScannerMatch[] }>(`/scanner/matches${query(params)}`),
|
|
setStatus: (id: number, status: ScannerMatchStatus) => patch<{ ok: true }>(`/scanner/matches/${id}`, { status }),
|
|
scan: () => post<{ started: true }>('/scanner/scan'),
|
|
settings: () => get<{ settings: ScannerSchedule; secrets: ScannerSecrets }>('/scanner/settings'),
|
|
saveSettings: (changes: Partial<ScannerSchedule> & Partial<Record<keyof ScannerSecrets, string>>) =>
|
|
patch<{ settings: ScannerSchedule; secrets: ScannerSecrets }>('/scanner/settings', changes),
|
|
};
|