// Мини-клиент API. Ошибки авторизации редиректят на вход.
import i18n from './i18n'
export interface Tag {
id: number
name: string
}
export interface Project {
id: number
name: string
relevance_status: string
priority: number | null
note: string
}
// Черновое предложение автодетализации (LLM)
export interface AiProposal {
tags: string[]
project: string | null
new_project: boolean
priority: number | null
estimated_minutes?: number | null
}
export interface Attachment {
id: number
task_id: number
original_name: string
mime: string
size: number
created_at: string
}
export interface Task {
id: number
title: string
description: string
task_type: string
status: string
detail_state: string
parent_task_id: number | null
project: Project | null
priority: number | null
tags: Tag[]
ai_proposal: AiProposal | null
estimated_minutes: number | null
actual_minutes: number | null
budget_money: number | null
cost_estimate_money: number | null
deadline_date: string | null
deadline_period: string | null
recur_kind: string | null
recur_interval_days: number | null
recur_weekdays: string | null
recur_day_of_month: number | null
created_at: string
approved_at: string | null
done_at: string | null
}
export interface TaskUpdateInput {
title?: string
description?: string
project_id?: number | null
parent_task_id?: number | null
tag_ids?: number[]
priority?: number | null
status?: string
estimated_minutes?: number | null
actual_minutes?: number | null
budget_money?: number | null
cost_estimate_money?: number | null
deadline_date?: string | null
deadline_period?: string | null
task_type?: string
recur_kind?: string | null
recur_interval_days?: number | null
recur_weekdays?: string | null
recur_day_of_month?: number | null
}
export interface AppSettings {
currency: string
/** '' = нет переопределения, язык берётся из SSO locale */
language: string
}
export type AppSettingsUpdate = Partial<AppSettings>
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
// Content-Type не задаётся для FormData — браузер проставит boundary сам.
const headers: Record<string, string> = {}
if (!(options.body instanceof FormData)) headers['Content-Type'] = 'application/json'
const res = await fetch(path, { headers, ...options })
if (res.status === 401) {
window.location.href = '/auth/login'
throw new ApiError(401, 'Not authenticated')
}
if (!res.ok) {
const body = await res.json().catch(() => ({ detail: res.statusText }))
throw new ApiError(res.status, body.detail ?? i18n.global.t('common.error'))
}
return res.json() as Promise<T>
}
export const api = {
// tasks
listTasks: (params: Record<string, string | number | null | undefined> = {}) => {
const qs = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v))
})
return request<Task[]>('/api/tasks?' + qs)
},
getTask: (id: number) => request<Task>(`/api/tasks/${id}`),
createTask: (title: string, description = '', parentTaskId?: number) =>
request<{ id: number }>('/api/tasks', {
method: 'POST',
body: JSON.stringify({
title,
description,
...(parentTaskId !== undefined ? { parent_task_id: parentTaskId } : {}),
}),
}),
updateTask: (id: number, patch: TaskUpdateInput) =>
request<Task>(`/api/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
approveTask: (id: number, applyProposal = false) =>
request<Task>(`/api/tasks/${id}/approve`, {
method: 'POST',
body: JSON.stringify({ apply_proposal: applyProposal }),
}),
redetailTask: (id: number) =>
request<Task>(`/api/tasks/${id}/redetail`, { method: 'POST' }),
suggestTasks: (availableMinutes: number) =>
request<Task[]>('/api/tasks/suggest', {
method: 'POST',
body: JSON.stringify({ available_minutes: availableMinutes }),
}),
deleteTask: (id: number) => request<{ ok: boolean }>(`/api/tasks/${id}`, { method: 'DELETE' }),
// attachments
listAttachments: (taskId: number) =>
request<Attachment[]>(`/api/tasks/${taskId}/attachments`),
uploadAttachments: (taskId: number, files: File[]) => {
const form = new FormData()
files.forEach((f) => form.append('files', f))
return request<Attachment[]>(`/api/tasks/${taskId}/attachments`, {
method: 'POST',
body: form,
})
},
deleteAttachment: (id: number) =>
request<{ ok: boolean }>(`/api/attachments/${id}`, { method: 'DELETE' }),
// Ссылка на файл вложения (нужна сессионная cookie — просто <img src>)
attachmentUrl: (id: number) => `/api/attachments/${id}/file`,
// projects
listProjects: () => request<Project[]>('/api/projects'),
getProject: (id: number) => request<Project>(`/api/projects/${id}`),
createProject: (name: string) =>
request<Project>('/api/projects', { method: 'POST', body: JSON.stringify({ name }) }),
updateProject: (id: number, patch: Partial<Omit<Project, 'id'>>) =>
request<Project>(`/api/projects/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
deleteProject: (id: number) =>
request<{ ok: boolean }>(`/api/projects/${id}`, { method: 'DELETE' }),
// tags
listTags: () => request<Tag[]>('/api/tags'),
createTag: (name: string) =>
request<Tag>('/api/tags', { method: 'POST', body: JSON.stringify({ name }) }),
// settings (глобальные: валюта бюджетов — выбирается один раз)
getSettings: () => request<AppSettings>('/api/settings'),
updateSettings: (patch: AppSettingsUpdate) =>
request<AppSettings>('/api/settings', { method: 'PUT', body: JSON.stringify(patch) }),
}