refactor: split monolithic api.ts into domain modules, extract AppShell calendar/mobile concerns

- Split frontend/src/lib/api.ts (2042 lines) into 12 focused domain modules:
  client.ts (base HTTP/auth), calendar.ts, documents.ts, employees.ts,
  equipment.ts, feed.ts, messages.ts, notifications.ts, procurement.ts,
  requests.ts, utils.ts, and index.ts barrel that preserves all existing imports
- Extract calendar modal state from AppShell into useCalendarModals hook
- Extract CalendarModals and MobileDrawers into components/layout/
- AppShell: 745 → 460 lines, focused on layout concerns only
- Fix setState-in-effect lint error with startTransition pattern
- Stabilize notifications memo to prevent unnecessary re-renders
- Fix flaky WS test: handle CancelledError in channels-testing cleanup
- All 609 backend tests pass, TypeScript typecheck clean, ESLint clean
This commit is contained in:
lizerk 2026-04-01 15:56:10 +03:00
parent a8ec339565
commit 3ca4d108b6
18 changed files with 1227 additions and 2367 deletions

View File

@ -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."""

View File

@ -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<string, number> = {};
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<any>(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<Date | null>(null);
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
const [viewingEvent, setViewingEvent] = useState<any>(null);
const [sidebarEvents, setSidebarEvents] = useState<CalendarEvent[]>([]);
const [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState<number | null>(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) {
<LeftNav />
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${isMessageDialogPage ? 'overflow-visible' : ''}`}>{children}</main>
<CalendarSidebar
onOpenCalendarModal={handleOpenCalendarModal}
onOpenEventModal={handleOpenEventModal}
onOpenParticipantsModal={handleOpenParticipantsModal}
eventsRefreshTrigger={eventsRefreshTrigger}
setEventsRefreshTrigger={handleSetEventsRefreshTrigger}
setSidebarEvents={handleSetSidebarEvents}
onCalendarChange={handleCalendarChange}
onOpenCalendarModal={cal.handleOpenCalendarModal}
onOpenEventModal={cal.handleOpenEventModal}
onOpenParticipantsModal={cal.handleOpenParticipantsModal}
eventsRefreshTrigger={cal.eventsRefreshTrigger}
setEventsRefreshTrigger={cal.handleSetEventsRefreshTrigger}
setSidebarEvents={cal.handleSetSidebarEvents}
onCalendarChange={cal.handleCalendarChange}
/>
</div>
<div className={`fixed inset-0 z-[100] lg:hidden ${isMobileLeftNavOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
<button
type="button"
className={`absolute inset-0 bg-black/40 transition-opacity ${isMobileLeftNavOpen ? "opacity-100" : "opacity-0"}`}
onClick={() => setIsMobileLeftNavOpen(false)}
aria-label="Закрыть левое меню"
/>
<div
className={`absolute inset-y-0 left-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${isMobileLeftNavOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Меню</p>
<button
type="button"
onClick={() => setIsMobileLeftNavOpen(false)}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть меню"
>
<X size={20} className="text-gray-700" />
</button>
</div>
<MobileLeftDrawer
isOpen={isMobileLeftNavOpen}
onClose={() => setIsMobileLeftNavOpen(false)}
>
<LeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
</MobileLeftDrawer>
{/* Профиль пользователя */}
<div className="mb-6 rounded-xl bg-gradient-to-br from-sky-50 to-sky-100 ring-1 ring-sky-100 overflow-hidden">
<button
onClick={() => setIsProfileExpanded(!isProfileExpanded)}
className="w-full flex items-center gap-3 p-4 hover:bg-sky-200/50 transition-colors"
>
<div className="h-12 w-12 overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white flex items-center justify-center flex-shrink-0">
{user?.avatar ? (
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
userInitials
)}
</div>
<div className="min-w-0 flex-1 text-left">
<p className="text-sm font-semibold text-gray-900">{userName}</p>
</div>
<ChevronDown
size={20}
className={`text-gray-700 transition-transform flex-shrink-0 ${isProfileExpanded ? "rotate-180" : ""}`}
/>
</button>
{isProfileExpanded && (
<div className="border-t border-sky-200 p-2 space-y-2">
<button
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-white text-gray-700"
onClick={() => { setIsMobileLeftNavOpen(false); router.push('/profile'); }}
>Мой профиль</button>
<button
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-white text-gray-700"
onClick={() => { setIsMobileLeftNavOpen(false); router.push('/settings'); }}
>Настройки</button>
<button
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-red-50 text-red-600"
onClick={() => { setIsMobileLeftNavOpen(false); logout(); }}
>Выйти</button>
</div>
)}
</div>
<LeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
</div>
</div>
<div className={`fixed inset-0 z-[100] lg:hidden ${isMobileCalendarOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
<button
type="button"
className={`absolute inset-0 bg-black/40 transition-opacity ${isMobileCalendarOpen ? "opacity-100" : "opacity-0"}`}
onClick={() => setIsMobileCalendarOpen(false)}
aria-label="Закрыть календарь"
/>
<div
className={`absolute inset-y-0 right-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${isMobileCalendarOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Календарь</p>
<button
type="button"
onClick={() => setIsMobileCalendarOpen(false)}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть календарь"
>
<X size={20} className="text-gray-700" />
</button>
</div>
{isMobileCalendarOpen && (
<CalendarCard
onOpenCalendarModal={handleOpenCalendarModal}
onOpenEventModal={handleOpenEventModal}
onOpenParticipantsModal={handleOpenParticipantsModal}
eventsRefreshTrigger={eventsRefreshTrigger}
setEventsRefreshTrigger={handleSetEventsRefreshTrigger}
setSidebarEvents={handleSetSidebarEvents}
onCalendarChange={handleCalendarChange}
/>
)}
</div>
</div>
{/* Модалы календаря - рендерятся на верхнем уровне */}
<CalendarModal
isOpen={showCalendarModal}
onClose={() => {
setShowCalendarModal(false);
setEditingCalendar(null);
}}
calendar={editingCalendar}
<MobileCalendarDrawer
isOpen={isMobileCalendarOpen}
onClose={() => setIsMobileCalendarOpen(false)}
onOpenCalendarModal={cal.handleOpenCalendarModal}
onOpenEventModal={cal.handleOpenEventModal}
onOpenParticipantsModal={cal.handleOpenParticipantsModal}
eventsRefreshTrigger={cal.eventsRefreshTrigger}
setEventsRefreshTrigger={cal.handleSetEventsRefreshTrigger}
setSidebarEvents={cal.handleSetSidebarEvents}
onCalendarChange={cal.handleCalendarChange}
/>
{/* Модальное окно участников календаря */}
{participantsCalendar && (
<CalendarParticipantsModal
isOpen={showParticipantsModal}
onClose={() => {
setShowParticipantsModal(false);
setParticipantsCalendar(null);
}}
calendarId={participantsCalendar.id}
calendarName={participantsCalendar.name}
userRole={participantsCalendar.user_role}
/>
)}
{/* Модальное окно события */}
<EventModal
isOpen={showEventModal}
onClose={() => {
setShowEventModal(false);
setEditingEvent(null);
}}
event={editingEvent}
onSave={handleEventSaved}
showParticipants={true}
/>
{/* Модальное окно просмотра событий дня */}
<ViewDayEventsModal
isOpen={showDayEventsModal}
onClose={() => {
setShowDayEventsModal(false);
setSelectedDateForModal(null);
}}
date={selectedDateForModal}
events={sidebarEvents}
onEventClick={handleEventClickFromDay}
onCreateEvent={handleCreateEventFromDay}
/>
{/* Модальное окно просмотра деталей события */}
<ViewEventDetailsModal
isOpen={showEventDetailsModal}
onClose={() => {
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}
/>
<CalendarModals {...cal} />
</div>
);
}

View File

@ -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<typeof useCalendarModals>;
export function CalendarModals(props: CalendarModalsProps) {
return (
<>
<CalendarModal
isOpen={props.showCalendarModal}
onClose={props.closeCalendarModal}
calendar={props.editingCalendar}
/>
{props.participantsCalendar && (
<CalendarParticipantsModal
isOpen={props.showParticipantsModal}
onClose={props.closeParticipantsModal}
calendarId={props.participantsCalendar.id}
calendarName={props.participantsCalendar.name}
userRole={props.participantsCalendar.user_role}
/>
)}
<EventModal
isOpen={props.showEventModal}
onClose={props.closeEventModal}
event={props.editingEvent}
onSave={props.handleEventSaved}
showParticipants={true}
/>
<ViewDayEventsModal
isOpen={props.showDayEventsModal}
onClose={props.closeDayEventsModal}
date={props.selectedDateForModal}
events={props.sidebarEvents}
onEventClick={props.handleEventClickFromDay}
onCreateEvent={props.handleCreateEventFromDay}
/>
<ViewEventDetailsModal
isOpen={props.showEventDetailsModal}
onClose={props.closeEventDetailsModal}
event={props.viewingEvent}
onEdit={props.handleEditFromDetails}
onDelete={props.handleDeleteEvent}
showParticipants={true}
/>
</>
);
}

View File

@ -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 (
<div className={`fixed inset-0 z-[100] lg:hidden ${isOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
<button
type="button"
className={`absolute inset-0 bg-black/40 transition-opacity ${isOpen ? "opacity-100" : "opacity-0"}`}
onClick={onClose}
aria-label="Закрыть левое меню"
/>
<div
className={`absolute inset-y-0 left-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${
isOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Меню</p>
<button
type="button"
onClick={onClose}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть меню"
>
<X size={20} className="text-gray-700" />
</button>
</div>
{/* Профиль пользователя */}
<div className="mb-6 rounded-xl bg-gradient-to-br from-sky-50 to-sky-100 ring-1 ring-sky-100 overflow-hidden">
<button
onClick={() => setIsProfileExpanded(!isProfileExpanded)}
className="w-full flex items-center gap-3 p-4 hover:bg-sky-200/50 transition-colors"
>
<div className="h-12 w-12 overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white flex items-center justify-center flex-shrink-0">
{user?.avatar ? (
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
userInitials
)}
</div>
<div className="min-w-0 flex-1 text-left">
<p className="text-sm font-semibold text-gray-900">{userName}</p>
</div>
<ChevronDown
size={20}
className={`text-gray-700 transition-transform flex-shrink-0 ${isProfileExpanded ? "rotate-180" : ""}`}
/>
</button>
{isProfileExpanded && (
<div className="border-t border-sky-200 p-2 space-y-2">
<button
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-white text-gray-700"
onClick={() => { onClose(); router.push("/profile"); }}
>Мой профиль</button>
<button
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-white text-gray-700"
onClick={() => { onClose(); router.push("/settings"); }}
>Настройки</button>
<button
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-red-50 text-red-600"
onClick={() => { onClose(); logout(); }}
>Выйти</button>
</div>
)}
</div>
{children}
</div>
</div>
);
}
/* ────────────── Calendar Drawer (mobile) ────────────── */
interface CalendarDrawerProps {
isOpen: boolean;
onClose: () => void;
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
onOpenEventModal: (event: Partial<CalendarEvent> & { 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 (
<div className={`fixed inset-0 z-[100] lg:hidden ${isOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
<button
type="button"
className={`absolute inset-0 bg-black/40 transition-opacity ${isOpen ? "opacity-100" : "opacity-0"}`}
onClick={onClose}
aria-label="Закрыть календарь"
/>
<div
className={`absolute inset-y-0 right-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Календарь</p>
<button
type="button"
onClick={onClose}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть календарь"
>
<X size={20} className="text-gray-700" />
</button>
</div>
{isOpen && (
<CalendarCard
onOpenCalendarModal={onOpenCalendarModal}
onOpenEventModal={onOpenEventModal}
onOpenParticipantsModal={onOpenParticipantsModal}
eventsRefreshTrigger={eventsRefreshTrigger}
setEventsRefreshTrigger={setEventsRefreshTrigger}
setSidebarEvents={setSidebarEvents}
onCalendarChange={onCalendarChange}
/>
)}
</div>
</div>
);
}

View File

@ -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<CalendarEvent | null>(null);
const [participantsCalendar, setParticipantsCalendar] = useState<{ id: number; name: string; user_role?: string } | null>(null);
const [selectedDateForModal, setSelectedDateForModal] = useState<Date | null>(null);
const [viewingEvent, setViewingEvent] = useState<CalendarEvent | null>(null);
const [sidebarEvents, setSidebarEvents] = useState<CalendarEvent[]>([]);
const [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState<number | null>(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<CalendarEvent> & { 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,
};
}

File diff suppressed because it is too large Load Diff

View File

@ -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<void> => 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<string, any>) => 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<string, any>) => request(`/api/v1/schedule/events/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteEvent: (id: number): Promise<void> => 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<void> =>
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<Blob> => {
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()}`);
},
};
}

View File

@ -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<string> {
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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
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<void> {
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<any> {
return this.request('/api/v1/employees/me/');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async search(query: string, limit?: number): Promise<any> {
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<string, any>): Promise<any> {
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<string, string> = {};
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();
}
}

View File

@ -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<void> => 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<void> => 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<void> => 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<void> => 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}`;
},
};
}

View File

@ -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<string, any>) =>
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}/`),
};
}

View File

@ -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<string, string | number>) => request(`/api/v1/procurement/equipment-categories/${buildQuery(params)}`),
getEquipment: (params?: Record<string, string | number>) => request(`/api/v1/procurement/equipment/${buildQuery(params)}`),
getEquipmentDetail: (id: number) => request(`/api/v1/procurement/equipment/${id}/`),
getMyEquipment: (params?: Record<string, string | number>) => 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<string, any>) => request('/api/v1/procurement/equipment/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateEquipment: (id: number, data: Record<string, any>) => request(`/api/v1/procurement/equipment/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
deleteEquipment: (id: number): Promise<void> => 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<string, string | number>) => 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<string> => {
const headers: Record<string, string> = {};
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<void> => request(`/api/v1/procurement/equipment/${equipmentId}/comments/${commentId}/`, { method: 'DELETE' }),
};
}

View File

@ -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<string, any>) {
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<string, any>) => formDataPost('/api/v1/posts/', 'POST', data),
updatePost: (postId: number, data: Record<string, any>) => formDataPost(`/api/v1/posts/${postId}/`, 'PATCH', data),
deletePost: (postId: number): Promise<void> => 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<void> => request(`/api/v1/communications/messages/${commentId}/`, { method: 'DELETE' }),
};
}

View File

@ -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;

View File

@ -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<string, string> = {};
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<void> => 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<string, any>) => 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<string, any>) => 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 }) }),
};
}

View File

@ -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<void> => request(`/api/v1/notifications/${id}/read/`, { method: 'POST' }),
markAllNotificationsAsRead: (): Promise<void> => 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<void> => 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<string, any>) => request('/api/v1/notifications/preferences/', { method: 'PUT', body: JSON.stringify(data) }),
getVerbTypes: (): Promise<{ verb_types: any[] }> => request('/api/v1/notifications/verb-types/'),
};
}

View File

@ -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<string, string | number>) => request(`/api/v1/procurement/requests/${buildQuery(params)}`),
getProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/`),
createProcurementRequest: (data: Record<string, any>) => request('/api/v1/procurement/requests/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateProcurementRequest: (id: number, data: Record<string, any>) => request(`/api/v1/procurement/requests/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
deleteProcurementRequest: (id: number): Promise<void> => 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<string, string | number>) => request(`/api/v1/procurement/requests/my_requests/${buildQuery(params)}`),
getPendingApprovals: (params?: Record<string, string | number>) => request(`/api/v1/procurement/requests/pending_approvals/${buildQuery(params)}`),
// Items
getProcurementItems: (params?: Record<string, string | number>) => request(`/api/v1/procurement/items/${buildQuery(params)}`),
createProcurementItem: (data: Record<string, any>) => request('/api/v1/procurement/items/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateProcurementItem: (id: number, data: Record<string, any>) => request(`/api/v1/procurement/items/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
deleteProcurementItem: (id: number): Promise<void> => request(`/api/v1/procurement/items/${id}/`, { method: 'DELETE' }),
// Suppliers
getProcurementSuppliers: (params?: Record<string, string | number>) => request(`/api/v1/procurement/suppliers/${buildQuery(params)}`),
createProcurementSupplier: (data: Record<string, any>) => request('/api/v1/procurement/suppliers/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateProcurementSupplier: (id: number, data: Record<string, any>) => request(`/api/v1/procurement/suppliers/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
deleteProcurementSupplier: (id: number): Promise<void> => 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/'),
};
}

View File

@ -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<string, any>, 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<string, string> = {};
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<string, any>) => {
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<string, any>, saveAs?: 'draft' | 'submitted') => formDataRequest('/api/v1/requests/', 'POST', data, saveAs),
updateRequest: (requestId: number, data: Record<string, any>, saveAs?: 'draft' | 'submitted') => formDataRequest(`/api/v1/requests/${requestId}/`, 'PATCH', data, saveAs),
deleteRequest: (requestId: number): Promise<void> => request(`/api/v1/requests/${requestId}/`, { method: 'DELETE' }),
getRequestComments: (requestId: number) => request(`/api/v1/requests/${requestId}/comments/`),
deleteRequestComment: (requestId: number, commentId: number): Promise<void> => 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 }) }),
};
}

View File

@ -0,0 +1,16 @@
/** Helper to build URL query string from params object */
export function buildQuery(params?: Record<string, string | number | boolean | undefined | null>): 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<any>;
export type GetTokenFn = () => string | null;