diff --git a/backend/tests/realtime/test_websocket_events.py b/backend/tests/realtime/test_websocket_events.py index 0287d3f4..088cb3d3 100644 --- a/backend/tests/realtime/test_websocket_events.py +++ b/backend/tests/realtime/test_websocket_events.py @@ -544,8 +544,16 @@ class TestChatMarkedReadEvent: import asyncio with pytest.raises(asyncio.TimeoutError): await comm2.receive_json_from(timeout=2) - await comm1.disconnect() - await comm2.disconnect() + + # Cleanup: after TimeoutError, communicator may be in cancelled state + try: + await comm2.disconnect() + except asyncio.CancelledError: + pass + try: + await comm1.disconnect() + except asyncio.CancelledError: + pass class TestNotificationStateEvents: """Тесты синхронизации состояния уведомлений через user channel.""" diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 956b4a36..067ef3b5 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,22 +1,17 @@ "use client"; -import { Bell, Building2, CalendarDays, ChevronDown, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users, Wallet, X, Sparkles } from "lucide-react"; +import { Building2, CalendarDays, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users, Wallet } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { ReactNode, useEffect, useRef, useState, useCallback, useMemo } from "react"; -import { apiClient } from "@/lib/api"; +import { ReactNode, startTransition, useEffect, useRef, useState, useMemo } from "react"; import { useUser } from "@/contexts/UserContext"; import { useNotifications } from "@/hooks/useApi"; import { getVerbCategory } from "@/lib/verbTranslations"; -import { CalendarModal } from "@/components/CalendarModal"; -import CalendarParticipantsModal from "@/components/CalendarParticipantsModal"; import { NotificationCenter, NotificationPanel } from "@/components/NotificationCenter"; -import { EventModal } from "@/components/EventModal"; -import { ViewDayEventsModal } from "@/components/ViewDayEventsModal"; -import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal"; import { CalendarSidebar } from "@/components/calendar/CalendarSidebar"; -import { CalendarCard } from "@/components/calendar/CalendarCard"; -import type { CalendarEvent } from "@/services/calendarService"; +import { useCalendarModals } from "@/hooks/useCalendarModals"; +import { CalendarModals } from "@/components/layout/CalendarModals"; +import { MobileLeftDrawer, MobileCalendarDrawer } from "@/components/layout/MobileDrawers"; type AppShellProps = { children: ReactNode; @@ -259,13 +254,13 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) { function LeftNavContent({ onNavigate }: LeftNavContentProps) { const pathname = usePathname(); const { notifications: notificationsData, markCategoryAsRead } = useNotifications(); - const notifications = Array.isArray(notificationsData) ? notificationsData : []; + const notifications = useMemo(() => Array.isArray(notificationsData) ? notificationsData : [], [notificationsData]); // Подсчет уведомлений по категориям const categoryCounts = useMemo(() => { const counts: Record = {}; - notifications.forEach((n: any) => { + notifications.forEach((n: { verb?: string; is_read?: boolean }) => { // Пропускаем прочитанные if (!n.verb || n.is_read) return; const category = getVerbCategory(n.verb); @@ -349,145 +344,19 @@ export function PageHeader({ title, subtitle, badge, eyebrow = "Раздел" }: } export function AppShell({ children }: AppShellProps) { - const { user, loading, logout } = useUser(); + const { user, loading } = useUser(); const router = useRouter(); const pathname = usePathname(); const isMessageDialogPage = pathname.startsWith('/messages/') && pathname !== '/messages' && !pathname.includes('/settings'); - // Вычисляем данные пользователя один раз на уровне AppShell - const userInitials = user - ? `${user.last_name?.[0] || ''}${user.first_name?.[0] || ''}` - : 'Г'; - const userName = user - ? `${user.last_name} ${user.first_name}`.trim() - : 'Гость'; - const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false); const [isMobileCalendarOpen, setIsMobileCalendarOpen] = useState(false); - const [isProfileExpanded, setIsProfileExpanded] = useState(false); - // Состояния модалов календаря - const [showCalendarModal, setShowCalendarModal] = useState(false); - const [showEventModal, setShowEventModal] = useState(false); - const [showParticipantsModal, setShowParticipantsModal] = useState(false); - const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null); - const [editingEvent, setEditingEvent] = useState(null); - const [participantsCalendar, setParticipantsCalendar] = useState<{ id: number; name: string; user_role?: string } | null>(null); - const [eventsRefreshTrigger, setEventsRefreshTrigger] = useState(0); - - // Новые модалы для просмотра - const [showDayEventsModal, setShowDayEventsModal] = useState(false); - const [selectedDateForModal, setSelectedDateForModal] = useState(null); - const [showEventDetailsModal, setShowEventDetailsModal] = useState(false); - const [viewingEvent, setViewingEvent] = useState(null); - const [sidebarEvents, setSidebarEvents] = useState([]); - const [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState(null); + const prevPathnameRef = useRef(pathname); - // Мемоизированные callback'и для предотвращения ререндеров CalendarCard - const handleSetSidebarEvents = useCallback((events: CalendarEvent[]) => { - setSidebarEvents(events); - }, []); - - const handleCalendarChange = useCallback((calendarId: number | null) => { - setCurrentSelectedCalendarId(calendarId); - }, []); - - const handleSetEventsRefreshTrigger = useCallback((value: number | ((prev: number) => number)) => { - setEventsRefreshTrigger(value); - }, []); - - // Обработчики модалов (мемоизированные для предотвращения ререндеров) - const handleOpenCalendarModal = useCallback((calendar?: { id?: number; name: string }) => { - setEditingCalendar(calendar || { name: "" }); - setShowCalendarModal(true); - }, []); - - const handleOpenEventModal = useCallback((event: any, date?: Date) => { - // Если передана дата (клик на дату), открываем модал просмотра дня - if (date && !event.id) { - setSelectedDateForModal(date); - setShowDayEventsModal(true); - } - // Если передано событие с id (клик на событие), открываем детали - else if (event.id) { - setViewingEvent(event); - setShowEventDetailsModal(true); - } - // Fallback - создание нового события - else { - setEditingEvent(event); - setShowEventModal(true); - } - }, []); - - // Создание события из модала просмотра дня - const handleCreateEventFromDay = useCallback(() => { - if (!currentSelectedCalendarId) { - alert("Сначала выберите календарь"); - return; - } - - if (!selectedDateForModal) return; - - const startDate = new Date(selectedDateForModal); - startDate.setHours(10, 0, 0, 0); - const endDate = new Date(selectedDateForModal); - endDate.setHours(11, 0, 0, 0); - - setEditingEvent({ - title: "", - description: "", - start: startDate.toISOString(), - end: endDate.toISOString(), - calendar: currentSelectedCalendarId, - color_event: "#3498db", - }); - - setShowDayEventsModal(false); - setShowEventModal(true); - }, [currentSelectedCalendarId, selectedDateForModal]); - - // Переход к редактированию из модала просмотра - const handleEditFromDetails = useCallback(() => { - setEditingEvent(viewingEvent); - setShowEventDetailsModal(false); - setShowEventModal(true); - }, [viewingEvent]); - - // Клик на событие из модала просмотра дня - const handleEventClickFromDay = useCallback(async (event: any) => { - setShowDayEventsModal(false); - - // Если это occurrence, загружаем базовое событие - if (event.is_recurring && event.event_id) { - try { - const fullEvent = await apiClient.getEvent(event.event_id); - setViewingEvent({ - ...fullEvent, - start: event.start, - end: event.end, - }); - setShowEventDetailsModal(true); - } catch (err) { - console.error("Ошибка загрузки события:", err); - } - } else { - setViewingEvent(event); - setShowEventDetailsModal(true); - } - }, []); - - const handleEventSaved = useCallback(() => { - // Обновляем список событий в CalendarCard - setEventsRefreshTrigger(prev => prev + 1); - }, []); - - const handleOpenParticipantsModal = useCallback((calendar: { id: number; name: string; user_role?: string }) => { - setParticipantsCalendar(calendar); - setShowParticipantsModal(true); - }, []); + const cal = useCalendarModals(); useEffect(() => { // Если загрузка завершена и пользователь не авторизован - редирект на логин @@ -496,9 +365,15 @@ export function AppShell({ children }: AppShellProps) { } }, [user, loading, router]); + // Close mobile drawers on route change useEffect(() => { - setIsMobileLeftNavOpen(false); - setIsMobileCalendarOpen(false); + if (prevPathnameRef.current !== pathname) { + prevPathnameRef.current = pathname; + startTransition(() => { + setIsMobileLeftNavOpen(false); + setIsMobileCalendarOpen(false); + }); + } }, [pathname]); useEffect(() => { @@ -554,192 +429,36 @@ export function AppShell({ children }: AppShellProps) {
{children}
-
- -
+ setIsMobileLeftNavOpen(false)} + > + setIsMobileLeftNavOpen(false)} /> + - {/* Профиль пользователя */} -
- - {isProfileExpanded && ( -
- - - -
- )} -
- - setIsMobileLeftNavOpen(false)} /> - - - -
- -
- {isMobileCalendarOpen && ( - - )} - - - - {/* Модалы календаря - рендерятся на верхнем уровне */} - { - setShowCalendarModal(false); - setEditingCalendar(null); - }} - calendar={editingCalendar} + setIsMobileCalendarOpen(false)} + onOpenCalendarModal={cal.handleOpenCalendarModal} + onOpenEventModal={cal.handleOpenEventModal} + onOpenParticipantsModal={cal.handleOpenParticipantsModal} + eventsRefreshTrigger={cal.eventsRefreshTrigger} + setEventsRefreshTrigger={cal.handleSetEventsRefreshTrigger} + setSidebarEvents={cal.handleSetSidebarEvents} + onCalendarChange={cal.handleCalendarChange} /> - {/* Модальное окно участников календаря */} - {participantsCalendar && ( - { - setShowParticipantsModal(false); - setParticipantsCalendar(null); - }} - calendarId={participantsCalendar.id} - calendarName={participantsCalendar.name} - userRole={participantsCalendar.user_role} - /> - )} - - {/* Модальное окно события */} - { - setShowEventModal(false); - setEditingEvent(null); - }} - event={editingEvent} - onSave={handleEventSaved} - showParticipants={true} - /> - - {/* Модальное окно просмотра событий дня */} - { - setShowDayEventsModal(false); - setSelectedDateForModal(null); - }} - date={selectedDateForModal} - events={sidebarEvents} - onEventClick={handleEventClickFromDay} - onCreateEvent={handleCreateEventFromDay} - /> - - {/* Модальное окно просмотра деталей события */} - { - setShowEventDetailsModal(false); - setViewingEvent(null); - }} - event={viewingEvent} - onEdit={handleEditFromDetails} - onDelete={async () => { - if (!viewingEvent?.id) return; - if (!confirm("Удалить это событие?")) return; - - try { - await apiClient.deleteEvent(viewingEvent.id); - setShowEventDetailsModal(false); - setViewingEvent(null); - handleEventSaved(); // Обновляем список событий - } catch (err) { - console.error("Ошибка удаления события:", err); - alert("Не удалось удалить событие"); - } - }} - showParticipants={true} - /> + ); } diff --git a/frontend/src/components/layout/CalendarModals.tsx b/frontend/src/components/layout/CalendarModals.tsx new file mode 100644 index 00000000..6e19a441 --- /dev/null +++ b/frontend/src/components/layout/CalendarModals.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { CalendarModal } from "@/components/CalendarModal"; +import CalendarParticipantsModal from "@/components/CalendarParticipantsModal"; +import { EventModal } from "@/components/EventModal"; +import { ViewDayEventsModal } from "@/components/ViewDayEventsModal"; +import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal"; +import type { useCalendarModals } from "@/hooks/useCalendarModals"; + +type CalendarModalsProps = ReturnType; + +export function CalendarModals(props: CalendarModalsProps) { + return ( + <> + + + {props.participantsCalendar && ( + + )} + + + + + + + + ); +} diff --git a/frontend/src/components/layout/MobileDrawers.tsx b/frontend/src/components/layout/MobileDrawers.tsx new file mode 100644 index 00000000..1a56ecc8 --- /dev/null +++ b/frontend/src/components/layout/MobileDrawers.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { ReactNode, useState } from "react"; +import { useRouter } from "next/navigation"; +import { X, ChevronDown } from "lucide-react"; +import { useUser } from "@/contexts/UserContext"; +import { CalendarCard } from "@/components/calendar/CalendarCard"; +import type { CalendarEvent } from "@/services/calendarService"; + +/* ────────────── Left Navigation Drawer (mobile) ────────────── */ + +interface LeftNavDrawerProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; // LeftNavContent rendered by parent +} + +export function MobileLeftDrawer({ isOpen, onClose, children }: LeftNavDrawerProps) { + const router = useRouter(); + const { user, logout } = useUser(); + const [isProfileExpanded, setIsProfileExpanded] = useState(false); + + const userInitials = user + ? `${user.last_name?.[0] || ""}${user.first_name?.[0] || ""}` + : "Г"; + const userName = user + ? `${user.last_name} ${user.first_name}`.trim() + : "Гость"; + + return ( +
+ +
+ + {/* Профиль пользователя */} +
+ + {isProfileExpanded && ( +
+ + + +
+ )} +
+ + {children} + + + ); +} + +/* ────────────── Calendar Drawer (mobile) ────────────── */ + +interface CalendarDrawerProps { + isOpen: boolean; + onClose: () => void; + onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void; + onOpenEventModal: (event: Partial & { id?: number }, date?: Date) => void; + onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void; + eventsRefreshTrigger: number; + setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void; + setSidebarEvents: (events: CalendarEvent[]) => void; + onCalendarChange: (calendarId: number | null) => void; +} + +export function MobileCalendarDrawer({ + isOpen, + onClose, + onOpenCalendarModal, + onOpenEventModal, + onOpenParticipantsModal, + eventsRefreshTrigger, + setEventsRefreshTrigger, + setSidebarEvents, + onCalendarChange, +}: CalendarDrawerProps) { + return ( +
+ +
+ {isOpen && ( + + )} + + + ); +} diff --git a/frontend/src/hooks/useCalendarModals.ts b/frontend/src/hooks/useCalendarModals.ts new file mode 100644 index 00000000..0009a4b6 --- /dev/null +++ b/frontend/src/hooks/useCalendarModals.ts @@ -0,0 +1,209 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { apiClient } from "@/lib/api"; +import type { CalendarEvent } from "@/services/calendarService"; + +export interface CalendarModalsState { + showCalendarModal: boolean; + showEventModal: boolean; + showParticipantsModal: boolean; + showDayEventsModal: boolean; + showEventDetailsModal: boolean; + editingCalendar: { id?: number; name: string } | null; + editingEvent: CalendarEvent | null; + participantsCalendar: { id: number; name: string; user_role?: string } | null; + selectedDateForModal: Date | null; + viewingEvent: CalendarEvent | null; + sidebarEvents: CalendarEvent[]; + currentSelectedCalendarId: number | null; + eventsRefreshTrigger: number; +} + +export function useCalendarModals() { + const [showCalendarModal, setShowCalendarModal] = useState(false); + const [showEventModal, setShowEventModal] = useState(false); + const [showParticipantsModal, setShowParticipantsModal] = useState(false); + const [showDayEventsModal, setShowDayEventsModal] = useState(false); + const [showEventDetailsModal, setShowEventDetailsModal] = useState(false); + + const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null); + const [editingEvent, setEditingEvent] = useState(null); + const [participantsCalendar, setParticipantsCalendar] = useState<{ id: number; name: string; user_role?: string } | null>(null); + const [selectedDateForModal, setSelectedDateForModal] = useState(null); + const [viewingEvent, setViewingEvent] = useState(null); + const [sidebarEvents, setSidebarEvents] = useState([]); + const [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState(null); + const [eventsRefreshTrigger, setEventsRefreshTrigger] = useState(0); + + const handleSetSidebarEvents = useCallback((events: CalendarEvent[]) => { + setSidebarEvents(events); + }, []); + + const handleCalendarChange = useCallback((calendarId: number | null) => { + setCurrentSelectedCalendarId(calendarId); + }, []); + + const handleSetEventsRefreshTrigger = useCallback((value: number | ((prev: number) => number)) => { + setEventsRefreshTrigger(value); + }, []); + + const handleOpenCalendarModal = useCallback((calendar?: { id?: number; name: string }) => { + setEditingCalendar(calendar || { name: "" }); + setShowCalendarModal(true); + }, []); + + const handleOpenEventModal = useCallback((event: Partial & { id?: number }, date?: Date) => { + if (date && !event.id) { + setSelectedDateForModal(date); + setShowDayEventsModal(true); + } else if (event.id) { + setViewingEvent(event as CalendarEvent); + setShowEventDetailsModal(true); + } else { + setEditingEvent(event as CalendarEvent); + setShowEventModal(true); + } + }, []); + + const handleCreateEventFromDay = useCallback(() => { + if (!currentSelectedCalendarId) { + alert("Сначала выберите календарь"); + return; + } + if (!selectedDateForModal) return; + + const startDate = new Date(selectedDateForModal); + startDate.setHours(10, 0, 0, 0); + const endDate = new Date(selectedDateForModal); + endDate.setHours(11, 0, 0, 0); + + setEditingEvent({ + title: "", + description: "", + start: startDate.toISOString(), + end: endDate.toISOString(), + calendar: currentSelectedCalendarId, + color_event: "#3498db", + } as CalendarEvent); + + setShowDayEventsModal(false); + setShowEventModal(true); + }, [currentSelectedCalendarId, selectedDateForModal]); + + const handleEditFromDetails = useCallback(() => { + setEditingEvent(viewingEvent); + setShowEventDetailsModal(false); + setShowEventModal(true); + }, [viewingEvent]); + + const handleEventClickFromDay = useCallback(async (event: CalendarEvent & { is_recurring?: boolean; event_id?: number }) => { + setShowDayEventsModal(false); + + if (event.is_recurring && event.event_id) { + try { + const fullEvent = await apiClient.getEvent(event.event_id); + setViewingEvent({ + ...fullEvent, + start: event.start, + end: event.end, + }); + setShowEventDetailsModal(true); + } catch (err) { + console.error("Ошибка загрузки события:", err); + } + } else { + setViewingEvent(event); + setShowEventDetailsModal(true); + } + }, []); + + const handleEventSaved = useCallback(() => { + setEventsRefreshTrigger(prev => prev + 1); + }, []); + + const handleOpenParticipantsModal = useCallback((calendar: { id: number; name: string; user_role?: string }) => { + setParticipantsCalendar(calendar); + setShowParticipantsModal(true); + }, []); + + const handleDeleteEvent = useCallback(async () => { + if (!viewingEvent?.id) return; + if (!confirm("Удалить это событие?")) return; + + try { + await apiClient.deleteEvent(viewingEvent.id); + setShowEventDetailsModal(false); + setViewingEvent(null); + setEventsRefreshTrigger(prev => prev + 1); + } catch (err) { + console.error("Ошибка удаления события:", err); + alert("Не удалось удалить событие"); + } + }, [viewingEvent]); + + const closeCalendarModal = useCallback(() => { + setShowCalendarModal(false); + setEditingCalendar(null); + }, []); + + const closeParticipantsModal = useCallback(() => { + setShowParticipantsModal(false); + setParticipantsCalendar(null); + }, []); + + const closeEventModal = useCallback(() => { + setShowEventModal(false); + setEditingEvent(null); + }, []); + + const closeDayEventsModal = useCallback(() => { + setShowDayEventsModal(false); + setSelectedDateForModal(null); + }, []); + + const closeEventDetailsModal = useCallback(() => { + setShowEventDetailsModal(false); + setViewingEvent(null); + }, []); + + return { + // State for CalendarSidebar/CalendarCard props + eventsRefreshTrigger, + handleSetEventsRefreshTrigger, + handleSetSidebarEvents, + handleCalendarChange, + + // Handlers for opening modals + handleOpenCalendarModal, + handleOpenEventModal, + handleOpenParticipantsModal, + + // Modal state and close handlers + showCalendarModal, + editingCalendar, + closeCalendarModal, + + showParticipantsModal, + participantsCalendar, + closeParticipantsModal, + + showEventModal, + editingEvent, + closeEventModal, + handleEventSaved, + + showDayEventsModal, + selectedDateForModal, + closeDayEventsModal, + sidebarEvents, + handleEventClickFromDay, + handleCreateEventFromDay, + + showEventDetailsModal, + viewingEvent, + closeEventDetailsModal, + handleEditFromDetails, + handleDeleteEvent, + }; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts deleted file mode 100644 index e10506d6..00000000 --- a/frontend/src/lib/api.ts +++ /dev/null @@ -1,2042 +0,0 @@ -/** - * 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; diff --git a/frontend/src/lib/api/calendar.ts b/frontend/src/lib/api/calendar.ts new file mode 100644 index 00000000..6bf05226 --- /dev/null +++ b/frontend/src/lib/api/calendar.ts @@ -0,0 +1,74 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { RequestFn, GetTokenFn } from './utils'; + +export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) { + return { + getCalendarEvents: (params?: { start?: string; end?: string; calendar?: number }) => { + const qp = new URLSearchParams(); + if (params?.start) qp.append('start', params.start); + if (params?.end) qp.append('end', params.end); + if (params?.calendar) qp.append('calendar', params.calendar.toString()); + const qs = qp.toString(); + return request(`/api/v1/schedule/events/${qs ? '?' + qs : ''}`); + }, + getMyEvents: (params?: { start?: string; end?: string }) => { + const qp = new URLSearchParams(); + if (params?.start) qp.append('start', params.start); + if (params?.end) qp.append('end', params.end); + const qs = qp.toString(); + return request(`/api/v1/schedule/events/my-events/${qs ? '?' + qs : ''}`); + }, + getCalendars: () => request('/api/v1/schedule/calendars/'), + getCalendar: (id: number) => request(`/api/v1/schedule/calendars/${id}/`), + createCalendar: (data: { name: string; slug?: string }) => request('/api/v1/schedule/calendars/', { method: 'POST', body: JSON.stringify(data) }), + updateCalendar: (id: number, data: { name?: string; slug?: string }) => request(`/api/v1/schedule/calendars/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteCalendar: (id: number): Promise => request(`/api/v1/schedule/calendars/${id}/`, { method: 'DELETE' }), + // Subscriptions + getCalendarSubscriptions: () => request('/api/v1/schedule/subscriptions/'), + subscribeToCalendar: (calendarId: number) => request('/api/v1/schedule/subscriptions/', { method: 'POST', body: JSON.stringify({ calendar: calendarId }) }), + unsubscribeFromCalendar: (subscriptionId: number) => request(`/api/v1/schedule/subscriptions/${subscriptionId}/`, { method: 'DELETE' }), + updateSubscription: (subscriptionId: number, data: Record) => request(`/api/v1/schedule/subscriptions/${subscriptionId}/`, { method: 'PATCH', body: JSON.stringify(data) }), + // Events + getEvent: (id: number) => request(`/api/v1/schedule/events/${id}/`), + createEvent: (data: { title: string; description?: string; start: string; end: string; calendar: number; color_event?: string; rule?: number | null }) => + request('/api/v1/schedule/events/', { method: 'POST', body: JSON.stringify(data) }), + updateEvent: (id: number, data: Record) => request(`/api/v1/schedule/events/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteEvent: (id: number): Promise => request(`/api/v1/schedule/events/${id}/`, { method: 'DELETE' }), + // Participants + getCalendarParticipants: (calendarId: number) => request(`/api/v1/schedule/calendars/${calendarId}/participants/`), + addCalendarParticipant: (calendarId: number, userId: number, distinction: string = 'viewer') => + request(`/api/v1/schedule/calendars/${calendarId}/add-participant/`, { method: 'POST', body: JSON.stringify({ user_id: userId, distinction }) }), + removeCalendarParticipant: (calendarId: number, userId: number): Promise => + request(`/api/v1/schedule/calendars/${calendarId}/remove-participant/${userId}/`, { method: 'DELETE' }), + getEventParticipants: (eventId: number) => request(`/api/v1/schedule/relations/?event=${eventId}`), + addEventParticipant: (eventId: number, userId: number, distinction: string = 'attendee') => + request('/api/v1/schedule/relations/', { method: 'POST', body: JSON.stringify({ event: eventId, object_id: userId, distinction }) }), + removeEventParticipant: (relationId: number) => request(`/api/v1/schedule/relations/${relationId}/`, { method: 'DELETE' }), + // Export/Import + exportCalendarToICS: async (calendarId: number): Promise => { + const response = await fetch(`/api/v1/schedule/calendars/${calendarId}/export-ical/`, { method: 'GET', headers: { 'Authorization': `Bearer ${getToken()}` } }); + if (!response.ok) throw new Error('Failed to export calendar'); + return response.blob(); + }, + importCalendarFromICS: async (calendarId: number, file: File) => { + const fd = new FormData(); fd.append('file', file); + const response = await fetch(`/api/v1/schedule/calendars/${calendarId}/import-ical/`, { method: 'POST', headers: { 'Authorization': `Bearer ${getToken()}` }, body: fd }); + if (!response.ok) { const e = await response.json(); throw new Error(e.error || 'Failed to import calendar'); } + return response.json(); + }, + // Rules + createRule: (data: { name: string; description?: string; frequency: string; params?: any }) => + request('/api/v1/schedule/rules/', { method: 'POST', body: JSON.stringify({ ...data, params: data.params ? JSON.stringify(data.params) : '{}' }) }), + updateRule: (ruleId: number, data: { name: string; description?: string; frequency: string; params?: any }) => + request(`/api/v1/schedule/rules/${ruleId}/`, { method: 'PUT', body: JSON.stringify({ ...data, params: data.params ? JSON.stringify(data.params) : '{}' }) }), + getRules: () => request('/api/v1/schedule/rules/'), + getRule: (ruleId: number) => request(`/api/v1/schedule/rules/${ruleId}/`), + getOccurrences: (params: { start: string; end: string; calendar?: number }) => { + const qp = new URLSearchParams(); + qp.append('start', params.start); + qp.append('end', params.end); + if (params.calendar) qp.append('calendar', params.calendar.toString()); + return request(`/api/v1/schedule/events/occurrences/?${qp.toString()}`); + }, + }; +} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 00000000..c1da1caf --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,197 @@ +/** + * Base API Client — token management, auth, HTTP request logic. + * Domain-specific methods live in separate modules. + */ + +interface LoginCredentials { + email?: string; + phone?: string; + password: string; +} + +interface LoginResponse { + access: string; + refresh: string; +} + +export class ApiClientBase { + private tokenKey = 'access_token'; + private refreshTokenKey = 'refresh_token'; + private isRefreshing = false; + private refreshSubscribers: Array<(token: string) => void> = []; + + 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); + } + + 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) { + this.clearToken(); + throw new Error('Refresh token expired'); + } + + const data: LoginResponse = await response.json(); + this.setToken(data.access); + if (data.refresh) { + this.setRefreshToken(data.refresh); + } + return data.access; + } + + async request(endpoint: string, options: RequestInit = {}): Promise { + const headers: Record = { + ...(options.headers as Record), + }; + + 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 }); + + 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 d = await retryResponse.json(); errorDetails = d.detail || JSON.stringify(d); } catch { errorDetails = retryResponse.statusText; } + throw new Error(`API Error: ${retryResponse.status} ${errorDetails}`); + } + if (options.method === 'DELETE' && retryResponse.status === 204) return undefined as T; + const cl = retryResponse.headers.get('content-length'); + if (cl === '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 d = await response.json(); errorDetails = d.detail || JSON.stringify(d); } catch { errorDetails = response.statusText; } + throw new Error(`API Error: ${response.status} ${errorDetails}`); + } + + 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(); + } + + // ── Auth ────────────────────────────────────────── + + 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); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async getCurrentUser(): Promise { + return this.request('/api/v1/employees/me/'); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async search(query: string, limit?: number): Promise { + const queryParams = new URLSearchParams(); + queryParams.append('q', query); + if (limit) queryParams.append('limit', limit.toString()); + return this.request(`/api/v1/search/?${queryParams.toString()}`); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async updateCurrentUserProfile(data: Record): 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(); + } +} diff --git a/frontend/src/lib/api/documents.ts b/frontend/src/lib/api/documents.ts new file mode 100644 index 00000000..4e67e88e --- /dev/null +++ b/frontend/src/lib/api/documents.ts @@ -0,0 +1,86 @@ +import type { RequestFn } from './utils'; + +export function createDocumentsApi(request: RequestFn) { + return { + getDocuments: (params?: { search?: string; type?: string; status?: string; page?: number; limit?: number; folder_id?: number }) => { + const qp = new URLSearchParams(); + if (params?.search) qp.append('search', params.search); + if (params?.type) qp.append('type', params.type); + if (params?.status) qp.append('status', params.status); + if (params?.page) qp.append('page', params.page.toString()); + if (params?.limit) qp.append('limit', params.limit.toString()); + if (params?.folder_id !== undefined) qp.append('folder_id', params.folder_id.toString()); + const qs = qp.toString(); + return request(`/api/v1/documents/${qs ? '?' + qs : ''}`); + }, + getDocument: (id: number) => request(`/api/v1/documents/${id}/`), + 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[] }) => { + const fd = new FormData(); + fd.append('title', data.title); + if (data.description) fd.append('description', data.description); + if (data.extracted_text) fd.append('extracted_text', data.extracted_text); + if (data.folder_id) fd.append('folder', String(data.folder_id)); + fd.append('sent_to_all', String(data.sent_to_all ?? true)); + if (data.acknowledgement_required !== undefined) fd.append('acknowledgement_required', String(data.acknowledgement_required)); + data.recipient_ids?.forEach(id => fd.append('recipient_ids', String(id))); + data.department_ids?.forEach(id => fd.append('department_ids', String(id))); + data.tag_ids?.forEach(id => fd.append('tag_ids', String(id))); + fd.append('file', data.file); + return request('/api/v1/documents/', { method: 'POST', body: fd }); + }, + updateDocument: (id: number, data: { title?: string; description?: string; file?: File; tag_ids?: number[]; folder?: number | null }) => { + const fd = new FormData(); + if (data.title !== undefined) fd.append('title', data.title); + if (data.description !== undefined) fd.append('description', data.description); + if (data.file) fd.append('file', data.file); + if (data.folder !== undefined) fd.append('folder', data.folder === null ? '' : String(data.folder)); + data.tag_ids?.forEach(tagId => fd.append('tag_ids', String(tagId))); + return request(`/api/v1/documents/${id}/`, { method: 'PATCH', body: fd }); + }, + deleteDocument: (id: number): Promise => request(`/api/v1/documents/${id}/`, { method: 'DELETE' }), + acknowledgeDocument: (id: number) => request(`/api/v1/documents/${id}/acknowledge/`, { method: 'POST', body: JSON.stringify({}) }), + getDocumentAcknowledgements: (id: number, search?: string) => { + const qp = new URLSearchParams(); + if (search) qp.append('search', search); + const qs = qp.toString(); + return request(`/api/v1/documents/${id}/acknowledgements/${qs ? '?' + qs : ''}`); + }, + // Folders + getFolders: (params?: { parent_id?: number; root?: boolean }) => { + const qp = new URLSearchParams(); + if (params?.parent_id !== undefined) qp.append('parent_id', params.parent_id.toString()); + if (params?.root) qp.append('root', 'true'); + const qs = qp.toString(); + return request(`/api/v1/folders/${qs ? '?' + qs : ''}`); + }, + getFolder: (id: number) => request(`/api/v1/folders/${id}/`), + createFolder: (data: { name: string; parent?: number | null }) => request('/api/v1/folders/', { method: 'POST', body: JSON.stringify(data) }), + updateFolder: (id: number, data: { name?: string; parent?: number | null }) => request(`/api/v1/folders/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteFolder: (id: number): Promise => request(`/api/v1/folders/${id}/`, { method: 'DELETE' }), + getFolderChildren: (id: number) => request(`/api/v1/folders/${id}/children/`), + getFolderDocuments: (id: number) => request(`/api/v1/folders/${id}/documents/`), + // Comments + getDocumentComments: (documentId: number) => request(`/api/v1/document-comments/?document=${documentId}`), + createDocumentComment: (data: { document: number; text: string; parent?: number }) => + request('/api/v1/document-comments/', { method: 'POST', body: JSON.stringify({ document_id: data.document, text: data.text, parent_id: data.parent }) }), + updateDocumentComment: (id: number, text: string) => request(`/api/v1/document-comments/${id}/`, { method: 'PATCH', body: JSON.stringify({ text }) }), + deleteDocumentComment: (id: number): Promise => request(`/api/v1/document-comments/${id}/`, { method: 'DELETE' }), + getCommentReplies: (commentId: number) => request(`/api/v1/document-comments/${commentId}/replies/`), + // Tags + getDocumentTags: () => request('/api/v1/document-tags/'), + getDocumentTag: (id: number) => request(`/api/v1/document-tags/${id}/`), + createDocumentTag: (data: { name: string; color?: string }) => request('/api/v1/document-tags/', { method: 'POST', body: JSON.stringify(data) }), + updateDocumentTag: (id: number, data: { name?: string; color?: string }) => request(`/api/v1/document-tags/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteDocumentTag: (id: number): Promise => request(`/api/v1/document-tags/${id}/`, { method: 'DELETE' }), + getDocumentsByTag: (tagId: number) => request(`/api/v1/document-tags/${tagId}/documents/`), + // Related + getRelatedDocuments: (id: number) => request(`/api/v1/documents/${id}/related/`), + addRelatedDocument: (id: number, relatedId: number) => request(`/api/v1/documents/${id}/add_related/`, { method: 'POST', body: JSON.stringify({ related_document_id: relatedId }) }), + removeRelatedDocument: (id: number, relatedId: number) => request(`/api/v1/documents/${id}/remove_related/`, { method: 'DELETE', body: JSON.stringify({ related_document_id: relatedId }) }), + // Thumbnails + getDocumentThumbnail: (id: number, size?: 'small' | 'medium' | 'large' | 'original'): string => { + const sizeParam = size ? `?size=${size}` : ''; + return `/api/v1/documents/${id}/thumbnail/${sizeParam}`; + }, + }; +} diff --git a/frontend/src/lib/api/employees.ts b/frontend/src/lib/api/employees.ts new file mode 100644 index 00000000..0a6550b0 --- /dev/null +++ b/frontend/src/lib/api/employees.ts @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { type RequestFn } from './utils'; + +export function createEmployeesApi(request: RequestFn) { + return { + getEmployees: (params?: { search?: string; department?: string; page?: number; limit?: number; is_active?: boolean }) => { + const qp = new URLSearchParams(); + if (params?.search) qp.append('search', params.search); + if (params?.department) qp.append('department', params.department); + if (params?.page) qp.append('page', params.page.toString()); + if (params?.limit) qp.append('limit', params.limit.toString()); + if (params?.is_active !== undefined) qp.append('active', params.is_active.toString()); + const qs = qp.toString(); + return request(`/api/v1/employees/${qs ? '?' + qs : ''}`); + }, + getEmployee: (id: number | string) => request(`/api/v1/employees/${id}/`), + updateEmployee: (id: number | string, data: Record) => + request(`/api/v1/employees/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), + uploadEmployeeAvatar: (id: number | string, file: File) => { + const fd = new FormData(); fd.append('avatar', file); + return request(`/api/v1/employees/${id}/`, { method: 'PATCH', body: fd }); + }, + createEmployeeAction: (data: { employee: number; action: string; date: string; comment?: string }) => + request('/api/v1/employee-actions/', { method: 'POST', body: JSON.stringify(data) }), + updateEmployeeAction: (actionId: number, data: { action?: string; date?: string; comment?: string }) => + request(`/api/v1/employee-actions/${actionId}/`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteEmployeeAction: (actionId: number) => + request(`/api/v1/employee-actions/${actionId}/`, { method: 'DELETE' }), + getDepartments: (params?: { search?: string; page?: number; limit?: number }) => { + const qp = new URLSearchParams(); + if (params?.search) qp.append('search', params.search); + if (params?.page) qp.append('page', params.page.toString()); + if (params?.limit) qp.append('limit', params.limit.toString()); + const qs = qp.toString(); + return request(`/api/v1/departments/${qs ? '?' + qs : ''}`); + }, + getDepartment: (id: number | string) => request(`/api/v1/departments/${id}/`), + }; +} diff --git a/frontend/src/lib/api/equipment.ts b/frontend/src/lib/api/equipment.ts new file mode 100644 index 00000000..20ed03f2 --- /dev/null +++ b/frontend/src/lib/api/equipment.ts @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { buildQuery, type RequestFn, type GetTokenFn } from './utils'; + +export function createEquipmentApi(request: RequestFn, getToken: GetTokenFn) { + return { + getEquipmentCategories: (params?: Record) => request(`/api/v1/procurement/equipment-categories/${buildQuery(params)}`), + getEquipment: (params?: Record) => request(`/api/v1/procurement/equipment/${buildQuery(params)}`), + getEquipmentDetail: (id: number) => request(`/api/v1/procurement/equipment/${id}/`), + getMyEquipment: (params?: Record) => request(`/api/v1/procurement/equipment/my_equipment/${buildQuery(params)}`), + getWarrantyExpiringEquipment: () => request('/api/v1/procurement/equipment/warranty_expiring/'), + generateEquipmentInventoryNumber: (): Promise<{ inventory_number: string }> => request('/api/v1/procurement/equipment/generate-inventory-number/'), + getEquipmentCreateOptions: () => request('/api/v1/procurement/equipment/create-options/'), + createEquipment: (data: Record) => request('/api/v1/procurement/equipment/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), + updateEquipment: (id: number, data: Record) => request(`/api/v1/procurement/equipment/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), + deleteEquipment: (id: number): Promise => request(`/api/v1/procurement/equipment/${id}/`, { method: 'DELETE' }), + transferEquipment: (id: number, data: { to_department?: number | null; to_person?: number | null; reason?: string }) => + request(`/api/v1/procurement/equipment/${id}/transfer/`, { method: 'POST', body: JSON.stringify(data) }), + writeOffEquipment: (id: number, reason: string) => + request(`/api/v1/procurement/equipment/${id}/write_off/`, { method: 'POST', body: JSON.stringify({ reason }) }), + addEquipmentMaintenance: (id: number, data: { type: string; description?: string; cost?: string | number; date?: string }) => + request(`/api/v1/procurement/equipment/${id}/add_maintenance/`, { method: 'POST', body: JSON.stringify(data) }), + getEquipmentTransferHistory: (id: number) => request(`/api/v1/procurement/equipment/${id}/transfer_history/`), + getMaintenanceRecords: (params?: Record) => request(`/api/v1/procurement/maintenance/${buildQuery(params)}`), + getEquipmentCategoryTree: () => request('/api/v1/procurement/equipment-categories/tree/'), + getEquipmentCategoryChildren: (categoryId: number) => request(`/api/v1/procurement/equipment-categories/${categoryId}/children/`), + getEquipmentQrCodeBlobUrl: async (equipmentId: number): Promise => { + const headers: Record = {}; + const token = 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); + }, + getEquipmentComments: (equipmentId: number) => request(`/api/v1/procurement/equipment/${equipmentId}/comments/`), + addEquipmentComment: (equipmentId: number, text: string) => request(`/api/v1/procurement/equipment/${equipmentId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }), + deleteEquipmentComment: (equipmentId: number, commentId: number): Promise => request(`/api/v1/procurement/equipment/${equipmentId}/comments/${commentId}/`, { method: 'DELETE' }), + }; +} diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts new file mode 100644 index 00000000..6ac3c353 --- /dev/null +++ b/frontend/src/lib/api/feed.ts @@ -0,0 +1,64 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { RequestFn, GetTokenFn } from './utils'; + +export function createFeedApi(request: RequestFn, getToken: GetTokenFn) { + async function formDataPost(url: string, method: string, data: Record) { + const fd = new FormData(); + if (data.title) fd.append('title', data.title); + if (data.body) fd.append('body', data.body); + if (data.content) fd.append('content', data.content); + if (data.type) fd.append('type', data.type); + if (data.department !== undefined) fd.append('department', String(data.department)); + if (data.image) fd.append('image', data.image); + if (data.attachment) fd.append('attachment', data.attachment); + if (data.attachments) data.attachments.forEach((f: File) => fd.append('attachments', f)); + const token = getToken(); + const headers: HeadersInit = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const response = await fetch(url, { method, headers, body: fd }); + if (!response.ok) { const t = await response.text(); console.error('API Error Response:', t); throw new Error(`API Error: ${response.status} ${response.statusText}`); } + return response.json(); + } + + return { + getPosts: (params?: { page?: number; search?: string; limit?: number }) => { + const qp = new URLSearchParams(); + if (params?.page) qp.append('page', params.page.toString()); + if (params?.search) qp.append('search', params.search); + if (params?.limit) qp.append('limit', params.limit.toString()); + const qs = qp.toString(); + return request(`/api/v1/posts/${qs ? '?' + qs : ''}`); + }, + createPost: (data: Record) => formDataPost('/api/v1/posts/', 'POST', data), + updatePost: (postId: number, data: Record) => formDataPost(`/api/v1/posts/${postId}/`, 'PATCH', data), + deletePost: (postId: number): Promise => request(`/api/v1/posts/${postId}/`, { method: 'DELETE' }), + likePost: (postId: number) => request(`/api/v1/posts/${postId}/like/`, { method: 'POST' }), + unlikePost: (postId: number) => request(`/api/v1/posts/${postId}/unlike/`, { method: 'POST' }), + getPostLikers: (postId: number) => request(`/api/v1/posts/${postId}/likers/`), + getComments: (params: { post: number; page?: number }) => { + const qp = new URLSearchParams(); + if (params.page) qp.append('page', params.page.toString()); + const qs = qp.toString(); + return request(`/api/v1/posts/${params.post}/comments/${qs ? '?' + qs : ''}`); + }, + createComment: async (postId: number, text: string, image?: File, attachment?: File) => { + if (image || attachment) { + const fd = new FormData(); fd.append('text', text); + if (image) fd.append('image', image); + if (attachment) fd.append('attachment', attachment); + const token = getToken(); + const headers: HeadersInit = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const response = await fetch(`/api/v1/posts/${postId}/comments/`, { method: 'POST', headers, body: fd }); + if (!response.ok) { const t = await response.text(); console.error('API Error Response:', t); throw new Error(`API Error: ${response.status} ${response.statusText}`); } + return response.json(); + } + return request(`/api/v1/posts/${postId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }); + }, + updateComment: async (commentId: number, text: string) => { + const response: any = await request(`/api/v1/communications/messages/${commentId}/`, { method: 'PATCH', body: JSON.stringify({ content: text }) }); + return { ...response, text: response.content || text }; + }, + deleteComment: (commentId: number): Promise => request(`/api/v1/communications/messages/${commentId}/`, { method: 'DELETE' }), + }; +} diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts new file mode 100644 index 00000000..3caf37bc --- /dev/null +++ b/frontend/src/lib/api/index.ts @@ -0,0 +1,32 @@ +/** + * API Client — assembled from domain modules. + * Import: import { apiClient } from "@/lib/api" + */ +import { ApiClientBase } from './client'; +import { createEmployeesApi } from './employees'; +import { createDocumentsApi } from './documents'; +import { createMessagesApi } from './messages'; +import { createNotificationsApi } from './notifications'; +import { createCalendarApi } from './calendar'; +import { createFeedApi } from './feed'; +import { createRequestsApi } from './requests'; +import { createEquipmentApi } from './equipment'; +import { createProcurementApi } from './procurement'; + +const base = new ApiClientBase(); +const req = base.request.bind(base); +const tok = base.getToken.bind(base); + +export const apiClient = Object.assign(base, { + ...createEmployeesApi(req), + ...createDocumentsApi(req), + ...createMessagesApi(req, tok), + ...createNotificationsApi(req), + ...createCalendarApi(req, tok), + ...createFeedApi(req, tok), + ...createRequestsApi(req, tok), + ...createEquipmentApi(req, tok), + ...createProcurementApi(req), +}); + +export default apiClient; diff --git a/frontend/src/lib/api/messages.ts b/frontend/src/lib/api/messages.ts new file mode 100644 index 00000000..4e2d6c7c --- /dev/null +++ b/frontend/src/lib/api/messages.ts @@ -0,0 +1,82 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { RequestFn, GetTokenFn } from './utils'; + +export function createMessagesApi(request: RequestFn, getToken: GetTokenFn) { + async function uploadMessage(chatId: number | string, text: string, files: File[], replyTo?: number) { + const fd = new FormData(); + fd.append('content', text); + fd.append('chat_id', chatId.toString()); + if (replyTo) fd.append('reply_to', replyTo.toString()); + files.forEach((file, i) => fd.append(`file_${i}`, file)); + const token = getToken(); + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const response = await fetch('/api/v1/communications/messages/upload/', { method: 'POST', headers, body: fd }); + if (!response.ok) { + let d = ''; try { const e = await response.json(); d = e.detail || JSON.stringify(e); } catch { d = response.statusText; } + throw new Error(`API Error: ${response.status} ${d}`); + } + const result = await response.json(); + return result.message || result; + } + + return { + getChats: (params?: { search?: string; page?: number }) => { + const qp = new URLSearchParams(); + if (params?.search) qp.append('search', params.search); + if (params?.page) qp.append('page', params.page.toString()); + const qs = qp.toString(); + return request(`/api/v1/communications/chats/${qs ? '?' + qs : ''}`); + }, + getChat: (chatId: number) => request(`/api/v1/communications/chats/${chatId}/`), + createChat: (data: { type: string; participants?: number[]; name?: string; description?: string; department?: number; include_all_employees?: boolean; avatar?: File }) => { + if (data.avatar) { + const fd = new FormData(); + fd.append('type', data.type); + if (data.name) fd.append('name', data.name); + if (data.description) fd.append('description', data.description); + if (data.avatar) fd.append('avatar', data.avatar); + data.participants?.forEach(id => fd.append('participants', id.toString())); + if (data.department) fd.append('department', data.department.toString()); + if (data.include_all_employees !== undefined) fd.append('include_all_employees', data.include_all_employees.toString()); + return request('/api/v1/communications/chats/', { method: 'POST', body: fd }); + } + return request('/api/v1/communications/chats/', { method: 'POST', body: JSON.stringify(data) }); + }, + getChatMessages: (chatId: number, params?: { limit?: number; before_id?: number; after_id?: number; mark_read?: boolean; before?: string; after?: string }) => { + const qp = new URLSearchParams(); + if (params?.limit) qp.append('limit', params.limit.toString()); + if (params?.before_id) qp.append('before_id', params.before_id.toString()); + if (params?.after_id) qp.append('after_id', params.after_id.toString()); + if (typeof params?.mark_read === 'boolean') qp.append('mark_read', String(params.mark_read)); + if (params?.before) qp.append('before', params.before); + if (params?.after) qp.append('after', params.after); + const qs = qp.toString(); + return request(`/api/v1/communications/chats/${chatId}/messages/${qs ? '?' + qs : ''}`); + }, + getChatMessagesAround: (chatId: number | string, params?: { limit?: number; around_id?: number }) => { + const qp = new URLSearchParams(); + if (params?.limit) qp.append('limit', params.limit.toString()); + if (params?.around_id) qp.append('around_id', params.around_id.toString()); + const qs = qp.toString(); + return request(`/api/v1/communications/chats/${chatId}/messages-around/${qs ? '?' + qs : ''}`); + }, + sendMessage: (chatId: number | string, text: string, replyTo?: number) => uploadMessage(chatId, text, [], replyTo), + sendMessageWithFiles: (chatId: number | string, text: string, files: File[], replyTo?: number) => uploadMessage(chatId, text, files, replyTo), + updateMessage: (messageId: number, text: string) => request(`/api/v1/communications/messages/${messageId}/`, { method: 'PATCH', body: JSON.stringify({ content: text }) }), + deleteMessage: (messageId: number): Promise => request(`/api/v1/communications/messages/${messageId}/`, { method: 'DELETE' }), + markChatAsRead: (chatId: number, lastReadMessageId?: number) => request(`/api/v1/communications/chats/${chatId}/mark-read/`, { method: 'POST', body: JSON.stringify({ message_id: lastReadMessageId }) }), + togglePinChat: (chatId: number) => request(`/api/v1/communications/chats/${chatId}/pin/`, { method: 'POST' }), + toggleChatNotifications: (chatId: number) => request(`/api/v1/communications/chats/${chatId}/notifications/`, { method: 'POST' }), + leaveChat: (chatId: number) => request(`/api/v1/communications/chats/${chatId}/leave/`, { method: 'POST' }), + deleteChat: (chatId: number) => request(`/api/v1/communications/chats/${chatId}/`, { method: 'DELETE' }), + updateChat: (chatId: number, data: Record) => request(`/api/v1/communications/chats/${chatId}/`, { method: 'PATCH', body: JSON.stringify(data) }), + uploadChatAvatar: (chatId: number, file: File) => { const fd = new FormData(); fd.append('avatar', file); return request(`/api/v1/communications/chats/${chatId}/`, { method: 'PATCH', body: fd }); }, + addChatMember: (chatId: number, userId: number) => request(`/api/v1/communications/chats/${chatId}/add-member/`, { method: 'POST', body: JSON.stringify({ user_id: userId }) }), + removeChatMember: (chatId: number, userId: number) => request(`/api/v1/communications/chats/${chatId}/remove-member/`, { method: 'POST', body: JSON.stringify({ user_id: userId }) }), + changeChatMemberRole: (chatId: number, userId: number, role: 'admin' | 'moderator' | 'member' | 'guest') => request(`/api/v1/communications/chats/${chatId}/change-role/`, { method: 'POST', body: JSON.stringify({ user_id: userId, role }) }), + updateChatUserSettings: (chatId: number, data: Record) => request(`/api/v1/communications/chats/${chatId}/user-settings/`, { method: 'PATCH', body: JSON.stringify(data) }), + reactToMessage: (messageId: number, emoji: string) => request(`/api/v1/communications/messages/${messageId}/react/`, { method: 'POST', body: JSON.stringify({ emoji }) }), + unreactToMessage: (messageId: number, emoji: string) => request(`/api/v1/communications/messages/${messageId}/unreact/`, { method: 'POST', body: JSON.stringify({ emoji }) }), + }; +} diff --git a/frontend/src/lib/api/notifications.ts b/frontend/src/lib/api/notifications.ts new file mode 100644 index 00000000..c4db064e --- /dev/null +++ b/frontend/src/lib/api/notifications.ts @@ -0,0 +1,37 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { RequestFn } from './utils'; + +export function createNotificationsApi(request: RequestFn) { + return { + getNotifications: (params?: { page?: number; page_size?: number; unread_only?: boolean }) => { + const qp = new URLSearchParams(); + if (params?.page) qp.append('page', String(params.page)); + if (params?.page_size) qp.append('page_size', String(params.page_size)); + if (params?.unread_only) qp.append('unread_only', 'true'); + const qs = qp.toString(); + return request(qs ? `/api/v1/notifications/?${qs}` : '/api/v1/notifications/'); + }, + getUnreadNotificationsCount: (): Promise<{ count: number }> => request('/api/v1/notifications/count/'), + markNotificationAsRead: (id: number): Promise => request(`/api/v1/notifications/${id}/read/`, { method: 'POST' }), + markAllNotificationsAsRead: (): Promise => request('/api/v1/notifications/read-all/', { method: 'POST' }), + markCategoryAsRead: async (category: string): Promise<{ status: string; count: number }> => { + const { getVerbsByCategory } = await import('../verbTranslations'); + const verbs = getVerbsByCategory(category); + return request('/api/v1/notifications/category/read/', { method: 'POST', body: JSON.stringify({ verbs, category }) }); + }, + // Push + getVapidPublicKey: (): Promise<{ vapid_public_key: string }> => request('/api/v1/notifications/push/vapid-key/'), + subscribePush: (data: { endpoint: string; keys: { p256dh: string; auth: string }; device_name?: string }): Promise<{ status: string; message: string; created: boolean }> => + request('/api/v1/notifications/push/subscribe/', { method: 'POST', body: JSON.stringify(data) }), + unsubscribePush: (endpoint?: string): Promise<{ status: string; message: string }> => + request('/api/v1/notifications/push/unsubscribe/', { method: 'DELETE', body: JSON.stringify({ endpoint }) }), + getPushSubscriptions: (): Promise<{ subscriptions: any[] }> => request('/api/v1/notifications/push/subscriptions/'), + // CRUD + deleteNotification: (id: number): Promise => request(`/api/v1/notifications/${id}/`, { method: 'DELETE' }), + deleteAllReadNotifications: (): Promise<{ status: string; count: number }> => request('/api/v1/notifications/delete-all-read/', { method: 'DELETE' }), + // Preferences + getNotificationPreferences: () => request('/api/v1/notifications/preferences/'), + updateNotificationPreferences: (data: Record) => request('/api/v1/notifications/preferences/', { method: 'PUT', body: JSON.stringify(data) }), + getVerbTypes: (): Promise<{ verb_types: any[] }> => request('/api/v1/notifications/verb-types/'), + }; +} diff --git a/frontend/src/lib/api/procurement.ts b/frontend/src/lib/api/procurement.ts new file mode 100644 index 00000000..940f2a4a --- /dev/null +++ b/frontend/src/lib/api/procurement.ts @@ -0,0 +1,37 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { buildQuery, type RequestFn } from './utils'; + +export function createProcurementApi(request: RequestFn) { + return { + getProcurementRequests: (params?: Record) => request(`/api/v1/procurement/requests/${buildQuery(params)}`), + getProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/`), + createProcurementRequest: (data: Record) => request('/api/v1/procurement/requests/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), + updateProcurementRequest: (id: number, data: Record) => request(`/api/v1/procurement/requests/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), + deleteProcurementRequest: (id: number): Promise => request(`/api/v1/procurement/requests/${id}/`, { method: 'DELETE' }), + submitProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/submit/`, { method: 'POST' }), + approveProcurementRequest: (id: number, comment?: string) => + request(`/api/v1/procurement/requests/${id}/approve/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ comment: comment || '' }) }), + rejectProcurementRequest: (id: number, comment?: string) => + request(`/api/v1/procurement/requests/${id}/reject/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ comment: comment || '' }) }), + startWorkProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/start_work/`, { method: 'POST' }), + completeProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/complete/`, { method: 'POST' }), + cancelProcurementRequest: (id: number, reason?: string) => + request(`/api/v1/procurement/requests/${id}/cancel/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: reason || '' }) }), + getMyProcurementRequests: (params?: Record) => request(`/api/v1/procurement/requests/my_requests/${buildQuery(params)}`), + getPendingApprovals: (params?: Record) => request(`/api/v1/procurement/requests/pending_approvals/${buildQuery(params)}`), + // Items + getProcurementItems: (params?: Record) => request(`/api/v1/procurement/items/${buildQuery(params)}`), + createProcurementItem: (data: Record) => request('/api/v1/procurement/items/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), + updateProcurementItem: (id: number, data: Record) => request(`/api/v1/procurement/items/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), + deleteProcurementItem: (id: number): Promise => request(`/api/v1/procurement/items/${id}/`, { method: 'DELETE' }), + // Suppliers + getProcurementSuppliers: (params?: Record) => request(`/api/v1/procurement/suppliers/${buildQuery(params)}`), + createProcurementSupplier: (data: Record) => request('/api/v1/procurement/suppliers/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), + updateProcurementSupplier: (id: number, data: Record) => request(`/api/v1/procurement/suppliers/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), + deleteProcurementSupplier: (id: number): Promise => request(`/api/v1/procurement/suppliers/${id}/`, { method: 'DELETE' }), + getTopRatedProcurementSuppliers: () => request('/api/v1/procurement/suppliers/top_rated/'), + // Stats + getProcurementOverviewStats: () => request('/api/v1/procurement/stats/overview/'), + getProcurementDepartmentStats: () => request('/api/v1/procurement/stats/by-department/'), + }; +} diff --git a/frontend/src/lib/api/requests.ts b/frontend/src/lib/api/requests.ts new file mode 100644 index 00000000..e7ecad85 --- /dev/null +++ b/frontend/src/lib/api/requests.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { RequestFn, GetTokenFn } from './utils'; + +export function createRequestsApi(request: RequestFn, getToken: GetTokenFn) { + async function formDataRequest(url: string, method: string, data: Record, saveAs?: 'draft' | 'submitted') { + const fd = new FormData(); + Object.keys(data).forEach((key) => { + if (key === 'attachments' && Array.isArray(data[key])) { data[key].forEach((f: File) => fd.append('attachments', f)); } + else if (key === 'attachment' && data[key] instanceof File) { fd.append('attachment', data[key]); } + else if (Array.isArray(data[key])) { data[key].forEach((v: any) => fd.append(key, String(v))); } + else if (data[key] !== null && data[key] !== undefined) { fd.append(key, String(data[key])); } + }); + if (saveAs) fd.append('save_as', saveAs); + const token = getToken(); + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const response = await fetch(url, { method, headers, body: fd }); + if (!response.ok) { let d = `${response.status} ${response.statusText}`; try { d = JSON.stringify(await response.json()); } catch {} throw new Error(d); } + return response.json(); + } + + return { + getRequests: (params?: Record) => { + const qp = new URLSearchParams(); + if (params) { + const keys = ['status','type','search','page','limit','view','addressed_to_me','employee_id','created_from','created_to','date_from','date_to']; + keys.forEach(k => { if (params[k]) qp.append(k, String(params[k])); }); + } + const qs = qp.toString(); + return request(`/api/v1/requests/${qs ? '?' + qs : ''}`); + }, + createRequest: (data: Record, saveAs?: 'draft' | 'submitted') => formDataRequest('/api/v1/requests/', 'POST', data, saveAs), + updateRequest: (requestId: number, data: Record, saveAs?: 'draft' | 'submitted') => formDataRequest(`/api/v1/requests/${requestId}/`, 'PATCH', data, saveAs), + deleteRequest: (requestId: number): Promise => request(`/api/v1/requests/${requestId}/`, { method: 'DELETE' }), + getRequestComments: (requestId: number) => request(`/api/v1/requests/${requestId}/comments/`), + deleteRequestComment: (requestId: number, commentId: number): Promise => request(`/api/v1/requests/${requestId}/comments/${commentId}/`, { method: 'DELETE' }), + approveRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/approve/`, { method: 'POST' }), + rejectRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/reject/`, { method: 'POST' }), + cancelRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/cancel/`, { method: 'POST' }), + addRequestComment: (requestId: number, text: string) => request(`/api/v1/requests/${requestId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }), + }; +} diff --git a/frontend/src/lib/api/utils.ts b/frontend/src/lib/api/utils.ts new file mode 100644 index 00000000..54632b96 --- /dev/null +++ b/frontend/src/lib/api/utils.ts @@ -0,0 +1,16 @@ +/** Helper to build URL query string from params object */ +export function buildQuery(params?: Record): string { + if (!params) return ''; + const qp = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + qp.append(key, String(value)); + } + }); + const qs = qp.toString(); + return qs ? `?${qs}` : ''; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RequestFn = (endpoint: string, options?: RequestInit) => Promise; +export type GetTokenFn = () => string | null;