diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 00000000..e10506d6 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,2042 @@ +/** + * API Client для работы с Django Backend через Next.js API proxy + * Все запросы идут на /api/* и проксируются на backend + */ + +interface LoginCredentials { + email?: string; + phone?: string; + password: string; +} + +interface LoginResponse { + access: string; + refresh: string; +} + +class ApiClient { + private tokenKey = 'access_token'; + private refreshTokenKey = 'refresh_token'; + private isRefreshing = false; + private refreshSubscribers: Array<(token: string) => void> = []; + + private getToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem(this.tokenKey); + } + + private getRefreshToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem(this.refreshTokenKey); + } + + private setToken(token: string): void { + if (typeof window !== 'undefined') { + localStorage.setItem(this.tokenKey, token); + } + } + + private setRefreshToken(token: string): void { + if (typeof window !== 'undefined') { + localStorage.setItem(this.refreshTokenKey, token); + } + } + + clearToken(): void { + if (typeof window !== 'undefined') { + localStorage.removeItem(this.tokenKey); + localStorage.removeItem(this.refreshTokenKey); + } + } + + private onRefreshed(token: string): void { + this.refreshSubscribers.forEach((callback) => callback(token)); + this.refreshSubscribers = []; + } + + private addRefreshSubscriber(callback: (token: string) => void): void { + this.refreshSubscribers.push(callback); + } + + /** + * Обновление access token с помощью refresh token + */ + private async refreshAccessToken(): Promise { + const refreshToken = this.getRefreshToken(); + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + const response = await fetch('/api/auth/token/refresh/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refresh: refreshToken }), + }); + + if (!response.ok) { + // Refresh token истёк или невалиден - нужно перелогиниться + this.clearToken(); + throw new Error('Refresh token expired'); + } + + const data: LoginResponse = await response.json(); + this.setToken(data.access); + + // Если бэкенд вернул новый refresh token, сохраняем его + if (data.refresh) { + this.setRefreshToken(data.refresh); + } + + return data.access; + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const headers: Record = { + ...(options.headers as Record), + }; + + // Устанавливаем Content-Type только если это не FormData + // Для FormData браузер сам установит правильный Content-Type с boundary + if (!(options.body instanceof FormData) && !headers['Content-Type']) { + headers['Content-Type'] = 'application/json'; + } + + // Автоматически добавляем токен если он есть + const token = this.getToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(endpoint, { + ...options, + headers, + }); + + // Если получили 401 и есть refresh token - пробуем обновить токен + if (response.status === 401 && this.getRefreshToken()) { + if (!this.isRefreshing) { + this.isRefreshing = true; + + try { + const newToken = await this.refreshAccessToken(); + this.isRefreshing = false; + this.onRefreshed(newToken); + + // Повторяем оригинальный запрос с новым токеном + headers['Authorization'] = `Bearer ${newToken}`; + const retryResponse = await fetch(endpoint, { + ...options, + headers, + }); + + if (!retryResponse.ok) { + let errorDetails = ''; + try { + const errorData = await retryResponse.json(); + errorDetails = errorData.detail || JSON.stringify(errorData); + } catch { + errorDetails = retryResponse.statusText; + } + throw new Error(`API Error: ${retryResponse.status} ${errorDetails}`); + } + + // Обрабатываем успешный ответ + if (options.method === 'DELETE' && retryResponse.status === 204) { + return undefined as T; + } + + const contentLength = retryResponse.headers.get('content-length'); + if (contentLength === '0' || retryResponse.status === 204) { + return undefined as T; + } + + return retryResponse.json(); + } catch (refreshError) { + this.isRefreshing = false; + this.refreshSubscribers = []; + throw refreshError; + } + } else { + // Если уже идёт обновление токена, ждём его завершения + return new Promise((resolve, reject) => { + this.addRefreshSubscriber((newToken: string) => { + headers['Authorization'] = `Bearer ${newToken}`; + fetch(endpoint, { ...options, headers }) + .then((res) => { + if (!res.ok) { + reject(new Error(`API Error: ${res.status}`)); + return; + } + if (options.method === 'DELETE' && res.status === 204) { + resolve(undefined as T); + return; + } + return res.json(); + }) + .then((data) => resolve(data)) + .catch(reject); + }); + }); + } + } + + if (!response.ok) { + // Попытка получить детали ошибки + let errorDetails = ''; + try { + const errorData = await response.json(); + errorDetails = errorData.detail || JSON.stringify(errorData); + } catch { + errorDetails = response.statusText; + } + throw new Error(`API Error: ${response.status} ${errorDetails}`); + } + + // Для DELETE запросов может не быть тела ответа + if (options.method === 'DELETE' && response.status === 204) { + return undefined as T; + } + + // Проверяем, есть ли контент в ответе + const contentLength = response.headers.get('content-length'); + if (contentLength === '0' || response.status === 204) { + return undefined as T; + } + + return response.json(); + } + + // Авторизация + async login(credentials: LoginCredentials): Promise { + const response = await fetch('/api/auth/token/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(credentials), + }); + + if (!response.ok) { + throw new Error(`Login failed: ${response.status}`); + } + + const data: LoginResponse = await response.json(); + this.setToken(data.access); + this.setRefreshToken(data.refresh); + } + + // Получение текущего пользователя + async getCurrentUser(): Promise { + return this.request('/api/v1/employees/me/'); + } + + // Глобальный поиск (django-watson) + async search(query: string, limit?: number): Promise { + const queryParams = new URLSearchParams(); + queryParams.append('q', query); + if (limit) queryParams.append('limit', limit.toString()); + + const url = `/api/v1/search/?${queryParams.toString()}`; + return this.request(url); + } + + // Сотрудники + async getEmployees(params?: { search?: string; department?: string; page?: number; limit?: number; is_active?: boolean }): Promise { + const queryParams = new URLSearchParams(); + if (params?.search) queryParams.append('search', params.search); + if (params?.department) queryParams.append('department', params.department); + if (params?.page) queryParams.append('page', params.page.toString()); + if (params?.limit) queryParams.append('limit', params.limit.toString()); + if (params?.is_active !== undefined) queryParams.append('active', params.is_active.toString()); + + const url = `/api/v1/employees/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async getEmployee(id: number | string): Promise { + return this.request(`/api/v1/employees/${id}/`); + } + + async updateEmployee(id: number | string, data: { + first_name?: string; + last_name?: string; + patronymic?: string; + email?: string; + phone_number?: string; + telegram?: string; + whatsapp?: string; + wechat?: string; + birth_date?: string; + gender?: string; + position_id?: number; + }): Promise { + return this.request(`/api/v1/employees/${id}/`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + } + + async uploadEmployeeAvatar(id: number | string, file: File): Promise { + const formData = new FormData(); + formData.append('avatar', file); + + return this.request(`/api/v1/employees/${id}/`, { + method: 'PATCH', + body: formData, + }); + } + + /** + * Создать кадровое событие (action) для сотрудника + */ + async createEmployeeAction(data: { + employee: number; + action: string; + date: string; + comment?: string; + }): Promise { + return this.request('/api/v1/employee-actions/', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + /** + * Обновить кадровое событие + */ + async updateEmployeeAction(actionId: number, data: { + action?: string; + date?: string; + comment?: string; + }): Promise { + return this.request(`/api/v1/employee-actions/${actionId}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + } + + /** + * Удалить кадровое событие + */ + async deleteEmployeeAction(actionId: number): Promise { + return this.request(`/api/v1/employee-actions/${actionId}/`, { + method: 'DELETE', + }); + } + + // Отделы + async getDepartments(params?: { search?: string; page?: number; limit?: number }): Promise { + const queryParams = new URLSearchParams(); + if (params?.search) queryParams.append('search', params.search); + if (params?.page) queryParams.append('page', params.page.toString()); + if (params?.limit) queryParams.append('limit', params.limit.toString()); + + const url = `/api/v1/departments/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async getDepartment(id: number | string): Promise { + return this.request(`/api/v1/departments/${id}/`); + } + + // Посты (лента новостей) + async getPosts(params?: { page?: number; search?: string; limit?: number }): Promise { + const queryParams = new URLSearchParams(); + if (params?.page) queryParams.append('page', params.page.toString()); + if (params?.search) queryParams.append('search', params.search); + if (params?.limit) queryParams.append('limit', params.limit.toString()); + + const url = `/api/v1/posts/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + // Документы + async getDocuments(params?: { search?: string; type?: string; status?: string; page?: number; limit?: number; folder_id?: number }): Promise { + const queryParams = new URLSearchParams(); + if (params?.search) queryParams.append('search', params.search); + if (params?.type) queryParams.append('type', params.type); + if (params?.status) queryParams.append('status', params.status); + if (params?.page) queryParams.append('page', params.page.toString()); + if (params?.limit) queryParams.append('limit', params.limit.toString()); + if (params?.folder_id !== undefined) queryParams.append('folder_id', params.folder_id.toString()); + + const url = `/api/v1/documents/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async getDocument(id: number): Promise { + return this.request(`/api/v1/documents/${id}/`); + } + + async createDocument(data: { + title: string; + description?: string; + file: File | Blob; + extracted_text?: string; + sent_to_all?: boolean; + recipient_ids?: number[]; + department_ids?: number[]; + folder_id?: number | null; + acknowledgement_required?: boolean; + tag_ids?: number[]; + }): Promise { + const formData = new FormData(); + formData.append('title', data.title); + if (data.description) formData.append('description', data.description); + if (data.extracted_text) formData.append('extracted_text', data.extracted_text); + if (data.folder_id) formData.append('folder', String(data.folder_id)); + + // Добавляем sent_to_all (по умолчанию true) + formData.append('sent_to_all', String(data.sent_to_all ?? true)); + + // Добавляем acknowledgement_required + if (data.acknowledgement_required !== undefined) { + formData.append('acknowledgement_required', String(data.acknowledgement_required)); + } + + // Добавляем получателей, если указаны + if (data.recipient_ids && data.recipient_ids.length > 0) { + data.recipient_ids.forEach(id => { + formData.append('recipient_ids', String(id)); + }); + } + + // Добавляем отделы, если указаны + if (data.department_ids && data.department_ids.length > 0) { + data.department_ids.forEach(id => { + formData.append('department_ids', String(id)); + }); + } + + // Добавляем теги, если указаны + if (data.tag_ids && data.tag_ids.length > 0) { + data.tag_ids.forEach(id => { + formData.append('tag_ids', String(id)); + }); + } + + formData.append('file', data.file); + + return this.request('/api/v1/documents/', { + method: 'POST', + body: formData, + }); + } + + async updateDocument(id: number, data: { + title?: string; + description?: string; + file?: File; + tag_ids?: number[]; + folder?: number | null; + }): Promise { + const formData = new FormData(); + if (data.title !== undefined) formData.append('title', data.title); + if (data.description !== undefined) formData.append('description', data.description); + if (data.file) formData.append('file', data.file); + + // Добавляем папку (может быть null) + if (data.folder !== undefined) { + if (data.folder === null) { + formData.append('folder', ''); + } else { + formData.append('folder', String(data.folder)); + } + } + + // Добавляем теги + if (data.tag_ids !== undefined) { + data.tag_ids.forEach(tagId => { + formData.append('tag_ids', String(tagId)); + }); + } + + return this.request(`/api/v1/documents/${id}/`, { + method: 'PATCH', + body: formData, + }); + } + + async deleteDocument(id: number): Promise { + return this.request(`/api/v1/documents/${id}/`, { + method: 'DELETE', + }); + } + + // Document acknowledgement + async acknowledgeDocument(id: number): Promise { + return this.request(`/api/v1/documents/${id}/acknowledge/`, { + method: 'POST', + body: JSON.stringify({}), + }); + } + + // Получить ведомость ознакомлений с документом (для начальника/автора) + async getDocumentAcknowledgements(id: number, search?: string): Promise { + const queryParams = new URLSearchParams(); + if (search) queryParams.append('search', search); + + const url = `/api/v1/documents/${id}/acknowledgements/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + // Папки документов + async getFolders(params?: { parent_id?: number; root?: boolean }): Promise { + const queryParams = new URLSearchParams(); + if (params?.parent_id !== undefined) queryParams.append('parent_id', params.parent_id.toString()); + if (params?.root) queryParams.append('root', 'true'); + + const url = `/api/v1/folders/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async getFolder(id: number): Promise { + return this.request(`/api/v1/folders/${id}/`); + } + + async createFolder(data: { name: string; parent?: number | null }): Promise { + return this.request('/api/v1/folders/', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateFolder(id: number, data: { name?: string; parent?: number | null }): Promise { + return this.request(`/api/v1/folders/${id}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + } + + async deleteFolder(id: number): Promise { + return this.request(`/api/v1/folders/${id}/`, { + method: 'DELETE', + }); + } + + async getFolderChildren(id: number): Promise { + return this.request(`/api/v1/folders/${id}/children/`); + } + + async getFolderDocuments(id: number): Promise { + return this.request(`/api/v1/folders/${id}/documents/`); + } + + // Document Comments + async getDocumentComments(documentId: number): Promise { + return this.request(`/api/v1/document-comments/?document=${documentId}`); + } + + async createDocumentComment(data: { document: number; text: string; parent?: number }): Promise { + return this.request('/api/v1/document-comments/', { + method: 'POST', + body: JSON.stringify({ + document_id: data.document, + text: data.text, + parent_id: data.parent, + }), + }); + } + + async updateDocumentComment(id: number, text: string): Promise { + return this.request(`/api/v1/document-comments/${id}/`, { + method: 'PATCH', + body: JSON.stringify({ text }), + }); + } + + async deleteDocumentComment(id: number): Promise { + return this.request(`/api/v1/document-comments/${id}/`, { + method: 'DELETE', + }); + } + + async getCommentReplies(commentId: number): Promise { + return this.request(`/api/v1/document-comments/${commentId}/replies/`); + } + + // Document Tags + async getDocumentTags(): Promise { + return this.request('/api/v1/document-tags/'); + } + + async getDocumentTag(id: number): Promise { + return this.request(`/api/v1/document-tags/${id}/`); + } + + async createDocumentTag(data: { name: string; color?: string }): Promise { + return this.request('/api/v1/document-tags/', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateDocumentTag(id: number, data: { name?: string; color?: string }): Promise { + return this.request(`/api/v1/document-tags/${id}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + } + + async deleteDocumentTag(id: number): Promise { + return this.request(`/api/v1/document-tags/${id}/`, { + method: 'DELETE', + }); + } + + async getDocumentsByTag(tagId: number): Promise { + return this.request(`/api/v1/document-tags/${tagId}/documents/`); + } + + // Related Documents + async getRelatedDocuments(id: number): Promise { + return this.request(`/api/v1/documents/${id}/related/`); + } + + async addRelatedDocument(id: number, relatedId: number): Promise { + return this.request(`/api/v1/documents/${id}/add_related/`, { + method: 'POST', + body: JSON.stringify({ related_document_id: relatedId }), + }); + } + + async removeRelatedDocument(id: number, relatedId: number): Promise { + return this.request(`/api/v1/documents/${id}/remove_related/`, { + method: 'DELETE', + body: JSON.stringify({ related_document_id: relatedId }), + }); + } + + // Document Thumbnails + getDocumentThumbnail(id: number, size?: 'small' | 'medium' | 'large' | 'original'): string { + const sizeParam = size ? `?size=${size}` : ''; + return `/api/v1/documents/${id}/thumbnail/${sizeParam}`; + } + + // Заявки + async getRequests(params?: { + status?: string; + type?: string; + search?: string; + page?: number; + limit?: number; + view?: string; + addressed_to_me?: string; + employee_id?: string | number; + created_from?: string; + created_to?: string; + date_from?: string; + date_to?: string; + [key: string]: any; + }): Promise { + const queryParams = new URLSearchParams(); + if (params?.status) queryParams.append('status', params.status); + if (params?.type) queryParams.append('type', params.type); + if (params?.search) queryParams.append('search', params.search); + if (params?.page) queryParams.append('page', params.page.toString()); + if (params?.limit) queryParams.append('limit', params.limit.toString()); + if (params?.view) queryParams.append('view', params.view); + if (params?.addressed_to_me) queryParams.append('addressed_to_me', params.addressed_to_me); + if (params?.employee_id) queryParams.append('employee_id', params.employee_id.toString()); + if (params?.created_from) queryParams.append('created_from', params.created_from); + if (params?.created_to) queryParams.append('created_to', params.created_to); + if (params?.date_from) queryParams.append('date_from', params.date_from); + if (params?.date_to) queryParams.append('date_to', params.date_to); + + const url = `/api/v1/requests/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async createRequest(data: any, saveAs?: 'draft' | 'submitted'): Promise { + const formData = new FormData(); + + Object.keys(data).forEach((key) => { + if (key === 'attachments' && Array.isArray(data[key])) { + data[key].forEach((file: File) => { + formData.append('attachments', file); + }); + } else if (key === 'attachment' && data[key] instanceof File) { + formData.append('attachment', data[key]); + } else if (Array.isArray(data[key])) { + data[key].forEach((val: any) => { + formData.append(key, String(val)); + }); + } else if (data[key] !== null && data[key] !== undefined) { + formData.append(key, String(data[key])); + } + }); + + if (saveAs) { + formData.append('save_as', saveAs); + } + + const token = this.getToken(); + const headers: Record = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch('/api/v1/requests/', { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + let detail = `${response.status} ${response.statusText}`; + try { + const body = await response.json(); + detail = JSON.stringify(body); + } catch {} + throw new Error(detail); + } + + return response.json(); + } + + async updateRequest(requestId: number, data: any, saveAs?: 'draft' | 'submitted'): Promise { + const formData = new FormData(); + + Object.keys(data).forEach((key) => { + if (key === 'attachments' && Array.isArray(data[key])) { + data[key].forEach((file: File) => { + formData.append('attachments', file); + }); + } else if (key === 'attachment' && data[key] instanceof File) { + formData.append('attachment', data[key]); + } else if (Array.isArray(data[key])) { + data[key].forEach((val: any) => { + formData.append(key, String(val)); + }); + } else if (data[key] !== null && data[key] !== undefined) { + formData.append(key, String(data[key])); + } + }); + + if (saveAs) { + formData.append('save_as', saveAs); + } + + const token = this.getToken(); + const headers: Record = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`/api/v1/requests/${requestId}/`, { + method: 'PATCH', + headers, + body: formData, + }); + + if (!response.ok) { + let detail = `${response.status} ${response.statusText}`; + try { + const body = await response.json(); + detail = JSON.stringify(body); + } catch {} + throw new Error(detail); + } + + return response.json(); + } + + async deleteRequest(requestId: number): Promise { + await this.request(`/api/v1/requests/${requestId}/`, { + method: 'DELETE', + }); + } + + async getRequestComments(requestId: number): Promise { + return this.request(`/api/v1/requests/${requestId}/comments/`); + } + + async deleteRequestComment(requestId: number, commentId: number): Promise { + await this.request(`/api/v1/requests/${requestId}/comments/${commentId}/`, { + method: 'DELETE', + }); + } + + // Чаты (основные методы чатов ниже в файле) + async getChatMessagesAround(chatId: number | string, params?: { limit?: number; around_id?: number }): Promise { + const queryParams = new URLSearchParams(); + if (params?.limit) queryParams.append('limit', params.limit.toString()); + if (params?.around_id) queryParams.append('around_id', params.around_id.toString()); + + const url = `/api/v1/communications/chats/${chatId}/messages-around/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async sendMessage(chatId: number | string, text: string, replyTo?: number): Promise { + const formData = new FormData(); + formData.append('content', text); + formData.append('chat_id', chatId.toString()); + if (replyTo) { + formData.append('reply_to', replyTo.toString()); + } + + const token = this.getToken(); + const headers: Record = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`/api/v1/communications/messages/upload/`, { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + let errorDetails = ''; + try { + const errorData = await response.json(); + errorDetails = errorData.detail || JSON.stringify(errorData); + } catch { + errorDetails = response.statusText; + } + throw new Error(`API Error: ${response.status} ${errorDetails}`); + } + + const result = await response.json(); + return result.message || result; + } + + async sendMessageWithFiles(chatId: number | string, text: string, files: File[], replyTo?: number): Promise { + const formData = new FormData(); + formData.append('content', text); + formData.append('chat_id', chatId.toString()); + if (replyTo) { + formData.append('reply_to', replyTo.toString()); + } + files.forEach((file, index) => { + formData.append(`file_${index}`, file); + }); + + const token = this.getToken(); + const headers: Record = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`/api/v1/communications/messages/upload/`, { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + let errorDetails = ''; + try { + const errorData = await response.json(); + errorDetails = errorData.detail || JSON.stringify(errorData); + } catch { + errorDetails = response.statusText; + } + throw new Error(`API Error: ${response.status} ${errorDetails}`); + } + + const result = await response.json(); + return result.message || result; + } + + async updateMessage(messageId: number, text: string): Promise { + return this.request(`/api/v1/communications/messages/${messageId}/`, { + method: 'PATCH', + body: JSON.stringify({ content: text }), + }); + } + + async deleteMessage(messageId: number): Promise { + await this.request(`/api/v1/communications/messages/${messageId}/`, { + method: 'DELETE', + }); + } + + // Уведомления + async getNotifications(params?: { page?: number; page_size?: number; unread_only?: boolean }): Promise { + const queryParams = new URLSearchParams(); + if (params?.page) queryParams.append('page', String(params.page)); + if (params?.page_size) queryParams.append('page_size', String(params.page_size)); + if (params?.unread_only) queryParams.append('unread_only', 'true'); + + const url = queryParams.toString() + ? `/api/v1/notifications/?${queryParams.toString()}` + : '/api/v1/notifications/'; + return this.request(url); + } + + async getUnreadNotificationsCount(): Promise<{ count: number }> { + return this.request('/api/v1/notifications/count/'); + } + + async markNotificationAsRead(id: number): Promise { + await this.request(`/api/v1/notifications/${id}/read/`, { + method: 'POST', + }); + } + + async markAllNotificationsAsRead(): Promise { + await this.request('/api/v1/notifications/read-all/', { + method: 'POST', + }); + } + + async markCategoryAsRead(category: string): Promise<{ status: string; count: number }> { + // Получаем список verb'ов для категории + const { getVerbsByCategory } = await import('./verbTranslations'); + const verbs = getVerbsByCategory(category); + + return this.request('/api/v1/notifications/category/read/', { + method: 'POST', + body: JSON.stringify({ verbs, category }), + }); + } + + // Web Push подписки + async getVapidPublicKey(): Promise<{ vapid_public_key: string }> { + return this.request('/api/v1/notifications/push/vapid-key/'); + } + + async subscribePush(data: { + endpoint: string; + keys: { p256dh: string; auth: string }; + device_name?: string; + }): Promise<{ status: string; message: string; created: boolean }> { + return this.request('/api/v1/notifications/push/subscribe/', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async unsubscribePush(endpoint?: string): Promise<{ status: string; message: string }> { + return this.request('/api/v1/notifications/push/unsubscribe/', { + method: 'DELETE', + body: JSON.stringify({ endpoint }), + }); + } + + async getPushSubscriptions(): Promise<{ subscriptions: any[] }> { + return this.request('/api/v1/notifications/push/subscriptions/'); + } + + // Дополнительные методы уведомлений + async deleteNotification(id: number): Promise { + await this.request(`/api/v1/notifications/${id}/`, { + method: 'DELETE', + }); + } + + async deleteAllReadNotifications(): Promise<{ status: string; count: number }> { + return this.request('/api/v1/notifications/delete-all-read/', { + method: 'DELETE', + }); + } + + async getNotificationPreferences(): Promise { + return this.request('/api/v1/notifications/preferences/'); + } + + async updateNotificationPreferences(data: { + web_enabled?: boolean; + email_enabled?: boolean; + email_frequency?: 'instant' | 'daily' | 'weekly' | 'disabled'; + push_enabled?: boolean; + dnd_enabled?: boolean; + dnd_start_time?: string; + dnd_end_time?: string; + disabled_verbs?: string[]; + }): Promise { + return this.request('/api/v1/notifications/preferences/', { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + async getVerbTypes(): Promise<{ verb_types: any[] }> { + return this.request('/api/v1/notifications/verb-types/'); + } + + // Календарь (django-scheduler API) + async getCalendarEvents(params?: { start?: string; end?: string; calendar?: number }): Promise { + const queryParams = new URLSearchParams(); + if (params?.start) queryParams.append('start', params.start); + if (params?.end) queryParams.append('end', params.end); + if (params?.calendar) queryParams.append('calendar', params.calendar.toString()); + + const url = `/api/v1/schedule/events/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async getMyEvents(params?: { start?: string; end?: string }): Promise { + const queryParams = new URLSearchParams(); + if (params?.start) queryParams.append('start', params.start); + if (params?.end) queryParams.append('end', params.end); + + const url = `/api/v1/schedule/events/my-events/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async getCalendars(): Promise { + return this.request('/api/v1/schedule/calendars/'); + } + + async getCalendarSubscriptions(): Promise { + return this.request('/api/v1/schedule/subscriptions/'); + } + + async subscribeToCalendar(calendarId: number): Promise { + return this.request('/api/v1/schedule/subscriptions/', { + method: 'POST', + body: JSON.stringify({ calendar: calendarId }), + }); + } + + async unsubscribeFromCalendar(subscriptionId: number): Promise { + return this.request(`/api/v1/schedule/subscriptions/${subscriptionId}/`, { + method: 'DELETE', + }); + } + + async updateSubscription(subscriptionId: number, data: any): Promise { + return this.request(`/api/v1/schedule/subscriptions/${subscriptionId}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + } + + async getCalendar(id: number): Promise { + return this.request(`/api/v1/schedule/calendars/${id}/`); + } + + async createCalendar(data: { name: string; slug?: string }): Promise { + return this.request('/api/v1/schedule/calendars/', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateCalendar(id: number, data: { name?: string; slug?: string }): Promise { + return this.request(`/api/v1/schedule/calendars/${id}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + } + + async deleteCalendar(id: number): Promise { + await this.request(`/api/v1/schedule/calendars/${id}/`, { + method: 'DELETE', + }); + } + + async getEvent(id: number): Promise { + return this.request(`/api/v1/schedule/events/${id}/`); + } + + async createEvent(data: { + title: string; + description?: string; + start: string; + end: string; + calendar: number; + color_event?: string; + rule?: number | null; + }): Promise { + return this.request('/api/v1/schedule/events/', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateEvent(id: number, data: { + title?: string; + description?: string; + start?: string; + end?: string; + calendar?: number; + color_event?: string; + rule?: number | null; + }): Promise { + return this.request(`/api/v1/schedule/events/${id}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + } + + async deleteEvent(id: number): Promise { + await this.request(`/api/v1/schedule/events/${id}/`, { + method: 'DELETE', + }); + } + + // Calendar Participants (CalendarRelation) + async getCalendarParticipants(calendarId: number): Promise { + return this.request(`/api/v1/schedule/calendars/${calendarId}/participants/`); + } + + async addCalendarParticipant(calendarId: number, userId: number, distinction: string = 'viewer'): Promise { + return this.request(`/api/v1/schedule/calendars/${calendarId}/add-participant/`, { + method: 'POST', + body: JSON.stringify({ user_id: userId, distinction }), + }); + } + + async removeCalendarParticipant(calendarId: number, userId: number): Promise { + await this.request(`/api/v1/schedule/calendars/${calendarId}/remove-participant/${userId}/`, { + method: 'DELETE', + }); + } + + // Calendar Export + async exportCalendarToICS(calendarId: number): Promise { + const response = await fetch(`/api/v1/schedule/calendars/${calendarId}/export-ical/`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.getToken()}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to export calendar'); + } + + return response.blob(); + } + + // Calendar Import + async importCalendarFromICS(calendarId: number, file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`/api/v1/schedule/calendars/${calendarId}/import-ical/`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.getToken()}`, + }, + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to import calendar'); + } + + return response.json(); + } + + // Recurring Events (Rules) + async createRule(data: { + name: string; + description?: string; + frequency: string; + params?: any; + }): Promise { + // Сериализуем params в JSON-строку для django-scheduler + const payload = { + ...data, + params: data.params ? JSON.stringify(data.params) : '{}', + }; + + return this.request('/api/v1/schedule/rules/', { + method: 'POST', + body: JSON.stringify(payload), + }); + } + + async updateRule(ruleId: number, data: { + name: string; + description?: string; + frequency: string; + params?: any; + }): Promise { + // Сериализуем params в JSON-строку для django-scheduler + const payload = { + ...data, + params: data.params ? JSON.stringify(data.params) : '{}', + }; + + return this.request(`/api/v1/schedule/rules/${ruleId}/`, { + method: 'PUT', + body: JSON.stringify(payload), + }); + } + + async getRules(): Promise { + return this.request('/api/v1/schedule/rules/'); + } + + async getRule(ruleId: number): Promise { + return this.request(`/api/v1/schedule/rules/${ruleId}/`); + } + + async getOccurrences(params: { start: string; end: string; calendar?: number }): Promise { + const queryParams = new URLSearchParams(); + queryParams.append('start', params.start); + queryParams.append('end', params.end); + if (params.calendar) queryParams.append('calendar', params.calendar.toString()); + + return this.request(`/api/v1/schedule/events/occurrences/?${queryParams.toString()}`); + } + + // Event Participants (EventRelation) + async getEventParticipants(eventId: number): Promise { + return this.request(`/api/v1/schedule/relations/?event=${eventId}`); + } + + async addEventParticipant(eventId: number, userId: number, distinction: string = 'attendee'): Promise { + return this.request('/api/v1/schedule/relations/', { + method: 'POST', + body: JSON.stringify({ + event: eventId, + object_id: userId, + distinction, + }), + }); + } + + async removeEventParticipant(relationId: number): Promise { + return this.request(`/api/v1/schedule/relations/${relationId}/`, { + method: 'DELETE', + }); + } + + // Посты - CRUD операции + async createPost(data: { + type?: 'company' | 'department' | 'employee'; + title?: string; + body?: string; + content?: string; + department?: number; + image?: File; + attachment?: File; + attachments?: File[]; + }): Promise { + const formData = new FormData(); + + // API требует title и body как отдельные поля + if (data.title) formData.append('title', data.title); + if (data.body) formData.append('body', data.body); + if (data.content) formData.append('content', data.content); + + // Опциональные поля + if (data.type) formData.append('type', data.type); + if (data.department) formData.append('department', String(data.department)); + if (data.image) formData.append('image', data.image); + if (data.attachment) formData.append('attachment', data.attachment); + + // Вложения (attachments) + if (data.attachments) { + data.attachments.forEach((file: File) => { + formData.append('attachments', file); + }); + } + + const token = this.getToken(); + const headers: HeadersInit = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch('/api/v1/posts/', { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('API Error Response:', errorText); + throw new Error(`API Error: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + + async updatePost(postId: number, data: { + type?: 'company' | 'department' | 'employee'; + title?: string; + body?: string; + content?: string; + department?: number; + image?: File; + attachment?: File; + attachments?: File[]; + }): Promise { + const formData = new FormData(); + + // API требует title и body как отдельные поля + if (data.title) formData.append('title', data.title); + if (data.body) formData.append('body', data.body); + if (data.content) formData.append('content', data.content); + + // Опциональные поля + if (data.type) formData.append('type', data.type); + if (data.department !== undefined) formData.append('department', String(data.department)); + if (data.image) formData.append('image', data.image); + if (data.attachment) formData.append('attachment', data.attachment); + + // Вложения (attachments) + if (data.attachments) { + data.attachments.forEach((file: File) => { + formData.append('attachments', file); + }); + } + + const token = this.getToken(); + const headers: HeadersInit = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`/api/v1/posts/${postId}/`, { + method: 'PATCH', + headers, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('API Error Response:', errorText); + throw new Error(`API Error: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + + async deletePost(postId: number): Promise { + await this.request(`/api/v1/posts/${postId}/`, { + method: 'DELETE', + }); + } + + // Лайки + async likePost(postId: number): Promise { + return this.request(`/api/v1/posts/${postId}/like/`, { + method: 'POST', + }); + } + + async unlikePost(postId: number): Promise { + return this.request(`/api/v1/posts/${postId}/unlike/`, { + method: 'POST', + }); + } + + async getPostLikers(postId: number): Promise { + return this.request(`/api/v1/posts/${postId}/likers/`); + } + + // Комментарии + async getComments(params: { post: number; page?: number }): Promise { + const queryParams = new URLSearchParams(); + if (params.page) queryParams.append('page', params.page.toString()); + + const url = `/api/v1/posts/${params.post}/comments/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async createComment(postId: number, text: string, image?: File, attachment?: File): Promise { + if (image || attachment) { + const formData = new FormData(); + formData.append('text', text); + if (image) formData.append('image', image); + if (attachment) formData.append('attachment', attachment); + + const token = this.getToken(); + const headers: HeadersInit = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`/api/v1/posts/${postId}/comments/`, { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('API Error Response:', errorText); + throw new Error(`API Error: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + + return this.request(`/api/v1/posts/${postId}/comments/`, { + method: 'POST', + body: JSON.stringify({ text }), + }); + } + + async updateComment(commentId: number, text: string): Promise { + const response = await this.request(`/api/v1/communications/messages/${commentId}/`, { + method: 'PATCH', + body: JSON.stringify({ content: text }), + }); + + // Конвертируем content -> text для совместимости + return { + ...response, + text: response.content || text, + }; + } + + async deleteComment(commentId: number): Promise { + await this.request(`/api/v1/communications/messages/${commentId}/`, { + method: 'DELETE', + }); + } + + // Управление заявками (approve/reject/cancel) + async approveRequest(requestId: number): Promise { + return this.request(`/api/v1/requests/${requestId}/approve/`, { + method: 'POST', + }); + } + + async rejectRequest(requestId: number): Promise { + return this.request(`/api/v1/requests/${requestId}/reject/`, { + method: 'POST', + }); + } + + async cancelRequest(requestId: number): Promise { + return this.request(`/api/v1/requests/${requestId}/cancel/`, { + method: 'POST', + }); + } + + async addRequestComment(requestId: number, text: string): Promise { + return this.request(`/api/v1/requests/${requestId}/comments/`, { + method: 'POST', + body: JSON.stringify({ text }), + }); + } + + // Профиль пользователя + async updateCurrentUserProfile(data: any): Promise { + const formData = new FormData(); + + Object.keys(data).forEach((key) => { + if (data[key] instanceof File) { + formData.append(key, data[key]); + } else if (data[key] !== null && data[key] !== undefined) { + formData.append(key, data[key]); + } + }); + + const token = this.getToken(); + const headers: Record = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch('/api/v1/employees/me/', { + method: 'PATCH', + headers, + body: formData, + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + + // Реакции на сообщения + async reactToMessage(messageId: number, emoji: string): Promise { + return this.request(`/api/v1/communications/messages/${messageId}/react/`, { + method: 'POST', + body: JSON.stringify({ emoji }), + }); + } + + async unreactToMessage(messageId: number, emoji: string): Promise { + return this.request(`/api/v1/communications/messages/${messageId}/unreact/`, { + method: 'POST', + body: JSON.stringify({ emoji }), + }); + } + + // ============= Communications (Chats & Messages) API ============= + + /** + * Получить список чатов пользователя + */ + async getChats(params?: { search?: string; page?: number }): Promise { + const queryParams = new URLSearchParams(); + if (params?.search) queryParams.append('search', params.search); + if (params?.page) queryParams.append('page', params.page.toString()); + + const query = queryParams.toString(); + return this.request(`/api/v1/communications/chats/${query ? '?' + query : ''}`); + } + + /** + * Получить детали чата по ID + */ + async getChat(chatId: number): Promise { + return this.request(`/api/v1/communications/chats/${chatId}/`); + } + + /** + * Создать новый чат + * type: 'private' | 'group' | 'department' | 'general' + */ + async createChat(data: { + type: string; + participants?: number[]; + name?: string; + description?: string; + department?: number; + include_all_employees?: boolean; + avatar?: File; + }): Promise { + // Если есть файл, используем FormData + if (data.avatar) { + const formData = new FormData(); + formData.append('type', data.type); + if (data.name) formData.append('name', data.name); + if (data.description) formData.append('description', data.description); + if (data.avatar) formData.append('avatar', data.avatar); + if (data.participants) { + data.participants.forEach(id => formData.append('participants', id.toString())); + } + if (data.department) formData.append('department', data.department.toString()); + if (data.include_all_employees !== undefined) { + formData.append('include_all_employees', data.include_all_employees.toString()); + } + + return this.request('/api/v1/communications/chats/', { + method: 'POST', + body: formData, + }); + } + + // Без файла - обычный JSON + return this.request('/api/v1/communications/chats/', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + /** + * Получить сообщения чата с пагинацией + */ + async getChatMessages( + chatId: number, + params?: { + limit?: number; + before_id?: number; + after_id?: number; + mark_read?: boolean; + before?: string; + after?: string; + } + ): Promise { + const queryParams = new URLSearchParams(); + if (params?.limit) queryParams.append('limit', params.limit.toString()); + if (params?.before_id) queryParams.append('before_id', params.before_id.toString()); + if (params?.after_id) queryParams.append('after_id', params.after_id.toString()); + if (typeof params?.mark_read === 'boolean') queryParams.append('mark_read', String(params.mark_read)); + if (params?.before) queryParams.append('before', params.before); + if (params?.after) queryParams.append('after', params.after); + + const query = queryParams.toString(); + return this.request(`/api/v1/communications/chats/${chatId}/messages/${query ? '?' + query : ''}`); + } + + /** + * Пометить чат как прочитанный + */ + async markChatAsRead(chatId: number, lastReadMessageId?: number): Promise { + return this.request(`/api/v1/communications/chats/${chatId}/mark-read/`, { + method: 'POST', + body: JSON.stringify({ + message_id: lastReadMessageId + }), + }); + } + + /** + * Закрепить/открепить чат + */ + async togglePinChat(chatId: number): Promise { + return this.request(`/api/v1/communications/chats/${chatId}/pin/`, { + method: 'POST', + }); + } + + /** + * Включить/выключить уведомления для чата + */ + async toggleChatNotifications(chatId: number): Promise { + return this.request(`/api/v1/communications/chats/${chatId}/notifications/`, { + method: 'POST', + }); + } + + /** + * Покинуть чат (для групповых чатов) + */ + async leaveChat(chatId: number): Promise { + return this.request(`/api/v1/communications/chats/${chatId}/leave/`, { + method: 'POST', + }); + } + + /** + * Удалить чат (требует прав администратора чата) + */ + async deleteChat(chatId: number): Promise { + return this.request(`/api/v1/communications/chats/${chatId}/`, { + method: 'DELETE', + }); + } + + /** + * Обновить информацию о чате (частичное обновление) + */ + async updateChat(chatId: number, data: Partial<{ + name: string; + description: string; + type: string; + can_reply: boolean; + include_all_users: boolean; + flags: Record; + extra_data: Record; + }>): Promise { + return this.request(`/api/v1/communications/chats/${chatId}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + } + + /** + * Загрузить аватар чата + */ + async uploadChatAvatar(chatId: number, file: File): Promise { + const formData = new FormData(); + formData.append('avatar', file); + + return this.request(`/api/v1/communications/chats/${chatId}/`, { + method: 'PATCH', + body: formData, + }); + } + + /** + * Добавить участника в чат + */ + async addChatMember(chatId: number, userId: number): Promise { + return this.request(`/api/v1/communications/chats/${chatId}/add-member/`, { + method: 'POST', + body: JSON.stringify({ user_id: userId }), + }); + } + + /** + * Удалить участника из чата + */ + async removeChatMember(chatId: number, userId: number): Promise { + return this.request(`/api/v1/communications/chats/${chatId}/remove-member/`, { + method: 'POST', + body: JSON.stringify({ user_id: userId }), + }); + } + + /** + * Изменить роль участника в чате + */ + async changeChatMemberRole(chatId: number, userId: number, role: 'admin' | 'moderator' | 'member' | 'guest'): Promise { + return this.request(`/api/v1/communications/chats/${chatId}/change-role/`, { + method: 'POST', + body: JSON.stringify({ user_id: userId, role }), + }); + } + + /** + * Обновить пользовательские настройки чата + */ + async updateChatUserSettings(chatId: number, data: Partial<{ + custom_name: string; + is_hidden: boolean; + }>): Promise { + // Примечание: может потребоваться отдельный endpoint на backend + // Сейчас используем PATCH чата, но лучше создать /api/v1/communications/chats/{id}/user-settings/ + return this.request(`/api/v1/communications/chats/${chatId}/user-settings/`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + } + + // ==================== Оборудование ==================== + + async getEquipmentCategories(params?: Record): Promise { + const queryParams = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams.append(key, String(value)); + } + }); + } + const url = `/api/v1/procurement/equipment-categories/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async getEquipment(params?: Record): Promise { + const queryParams = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams.append(key, String(value)); + } + }); + } + const url = `/api/v1/procurement/equipment/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async getEquipmentDetail(equipmentId: number): Promise { + return this.request(`/api/v1/procurement/equipment/${equipmentId}/`); + } + + async getMyEquipment(params?: Record): Promise { + const queryParams = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams.append(key, String(value)); + } + }); + } + const url = `/api/v1/procurement/equipment/my_equipment/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async getWarrantyExpiringEquipment(): Promise { + return this.request('/api/v1/procurement/equipment/warranty_expiring/'); + } + + async generateEquipmentInventoryNumber(): Promise<{ inventory_number: string }> { + return this.request('/api/v1/procurement/equipment/generate-inventory-number/'); + } + + async getEquipmentCreateOptions(): Promise { + return this.request('/api/v1/procurement/equipment/create-options/'); + } + + async createEquipment(data: Record): Promise { + return this.request('/api/v1/procurement/equipment/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + } + + async updateEquipment(equipmentId: number, data: Record): Promise { + return this.request(`/api/v1/procurement/equipment/${equipmentId}/`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + } + + async deleteEquipment(equipmentId: number): Promise { + await this.request(`/api/v1/procurement/equipment/${equipmentId}/`, { + method: 'DELETE', + }); + } + + async transferEquipment(equipmentId: number, data: { to_department?: number | null; to_person?: number | null; reason?: string }): Promise { + return this.request(`/api/v1/procurement/equipment/${equipmentId}/transfer/`, { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async writeOffEquipment(equipmentId: number, reason: string): Promise { + return this.request(`/api/v1/procurement/equipment/${equipmentId}/write_off/`, { + method: 'POST', + body: JSON.stringify({ reason }), + }); + } + + async addEquipmentMaintenance(equipmentId: number, data: { type: string; description?: string; cost?: string | number; date?: string }): Promise { + return this.request(`/api/v1/procurement/equipment/${equipmentId}/add_maintenance/`, { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async getEquipmentTransferHistory(equipmentId: number): Promise { + return this.request(`/api/v1/procurement/equipment/${equipmentId}/transfer_history/`); + } + + async getMaintenanceRecords(params?: Record): Promise { + const queryParams = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams.append(key, String(value)); + } + }); + } + const url = `/api/v1/procurement/maintenance/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async getEquipmentCategoryTree(): Promise { + return this.request('/api/v1/procurement/equipment-categories/tree/'); + } + + async getEquipmentCategoryChildren(categoryId: number): Promise { + return this.request(`/api/v1/procurement/equipment-categories/${categoryId}/children/`); + } + + async getEquipmentQrCodeBlobUrl(equipmentId: number): Promise { + const headers: Record = {}; + const token = this.getToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`/api/v1/procurement/equipment/${equipmentId}/qr_code/`, { + method: 'GET', + headers, + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status} ${response.statusText}`); + } + + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + + async getEquipmentComments(equipmentId: number): Promise { + return this.request(`/api/v1/procurement/equipment/${equipmentId}/comments/`); + } + + async addEquipmentComment(equipmentId: number, text: string): Promise { + return this.request(`/api/v1/procurement/equipment/${equipmentId}/comments/`, { + method: 'POST', + body: JSON.stringify({ text }), + }); + } + + async deleteEquipmentComment(equipmentId: number, commentId: number): Promise { + await this.request(`/api/v1/procurement/equipment/${equipmentId}/comments/${commentId}/`, { + method: 'DELETE', + }); + } + + // ==================== Закупки (Procurement Requests) ==================== + + async getProcurementRequests(params?: Record): Promise { + const queryParams = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams.append(key, String(value)); + } + }); + } + const url = `/api/v1/procurement/requests/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async getProcurementRequest(id: number): Promise { + return this.request(`/api/v1/procurement/requests/${id}/`); + } + + async createProcurementRequest(data: any): Promise { + return this.request('/api/v1/procurement/requests/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + } + + async updateProcurementRequest(id: number, data: any): Promise { + return this.request(`/api/v1/procurement/requests/${id}/`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + } + + async deleteProcurementRequest(id: number): Promise { + await this.request(`/api/v1/procurement/requests/${id}/`, { + method: 'DELETE', + }); + } + + async submitProcurementRequest(id: number): Promise { + return this.request(`/api/v1/procurement/requests/${id}/submit/`, { + method: 'POST', + }); + } + + async approveProcurementRequest(id: number, comment?: string): Promise { + return this.request(`/api/v1/procurement/requests/${id}/approve/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ comment: comment || '' }), + }); + } + + async rejectProcurementRequest(id: number, comment?: string): Promise { + return this.request(`/api/v1/procurement/requests/${id}/reject/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ comment: comment || '' }), + }); + } + + async startWorkProcurementRequest(id: number): Promise { + return this.request(`/api/v1/procurement/requests/${id}/start_work/`, { + method: 'POST', + }); + } + + async completeProcurementRequest(id: number): Promise { + return this.request(`/api/v1/procurement/requests/${id}/complete/`, { + method: 'POST', + }); + } + + async cancelProcurementRequest(id: number, reason?: string): Promise { + return this.request(`/api/v1/procurement/requests/${id}/cancel/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: reason || '' }), + }); + } + + async getMyProcurementRequests(params?: Record): Promise { + const queryParams = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams.append(key, String(value)); + } + }); + } + const url = `/api/v1/procurement/requests/my_requests/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async getPendingApprovals(params?: Record): Promise { + const queryParams = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams.append(key, String(value)); + } + }); + } + const url = `/api/v1/procurement/requests/pending_approvals/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + // ==================== Позиции закупок (Procurement Items) ==================== + + async getProcurementItems(params?: Record): Promise { + const queryParams = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams.append(key, String(value)); + } + }); + } + const url = `/api/v1/procurement/items/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async createProcurementItem(data: any): Promise { + return this.request('/api/v1/procurement/items/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + } + + async updateProcurementItem(id: number, data: any): Promise { + return this.request(`/api/v1/procurement/items/${id}/`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + } + + async deleteProcurementItem(id: number): Promise { + await this.request(`/api/v1/procurement/items/${id}/`, { + method: 'DELETE', + }); + } + + // ==================== Поставщики закупок ==================== + + async getProcurementSuppliers(params?: Record): Promise { + const queryParams = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams.append(key, String(value)); + } + }); + } + const url = `/api/v1/procurement/suppliers/${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + return this.request(url); + } + + async createProcurementSupplier(data: any): Promise { + return this.request('/api/v1/procurement/suppliers/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + } + + async updateProcurementSupplier(id: number, data: any): Promise { + return this.request(`/api/v1/procurement/suppliers/${id}/`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + } + + async deleteProcurementSupplier(id: number): Promise { + await this.request(`/api/v1/procurement/suppliers/${id}/`, { + method: 'DELETE', + }); + } + + async getTopRatedProcurementSuppliers(): Promise { + return this.request('/api/v1/procurement/suppliers/top_rated/'); + } + + // ==================== Статистика закупок ==================== + + async getProcurementOverviewStats(): Promise { + return this.request('/api/v1/procurement/stats/overview/'); + } + + async getProcurementDepartmentStats(): Promise { + return this.request('/api/v1/procurement/stats/by-department/'); + } +} + +export const apiClient = new ApiClient(); +export default apiClient;