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:
parent
a8ec339565
commit
3ca4d108b6
@ -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."""
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
58
frontend/src/components/layout/CalendarModals.tsx
Normal file
58
frontend/src/components/layout/CalendarModals.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
frontend/src/components/layout/MobileDrawers.tsx
Normal file
163
frontend/src/components/layout/MobileDrawers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
frontend/src/hooks/useCalendarModals.ts
Normal file
209
frontend/src/hooks/useCalendarModals.ts
Normal 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
74
frontend/src/lib/api/calendar.ts
Normal file
74
frontend/src/lib/api/calendar.ts
Normal 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()}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
197
frontend/src/lib/api/client.ts
Normal file
197
frontend/src/lib/api/client.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
86
frontend/src/lib/api/documents.ts
Normal file
86
frontend/src/lib/api/documents.ts
Normal 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}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
39
frontend/src/lib/api/employees.ts
Normal file
39
frontend/src/lib/api/employees.ts
Normal 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}/`),
|
||||
};
|
||||
}
|
||||
39
frontend/src/lib/api/equipment.ts
Normal file
39
frontend/src/lib/api/equipment.ts
Normal 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' }),
|
||||
};
|
||||
}
|
||||
64
frontend/src/lib/api/feed.ts
Normal file
64
frontend/src/lib/api/feed.ts
Normal 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' }),
|
||||
};
|
||||
}
|
||||
32
frontend/src/lib/api/index.ts
Normal file
32
frontend/src/lib/api/index.ts
Normal 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;
|
||||
82
frontend/src/lib/api/messages.ts
Normal file
82
frontend/src/lib/api/messages.ts
Normal 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 }) }),
|
||||
};
|
||||
}
|
||||
37
frontend/src/lib/api/notifications.ts
Normal file
37
frontend/src/lib/api/notifications.ts
Normal 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/'),
|
||||
};
|
||||
}
|
||||
37
frontend/src/lib/api/procurement.ts
Normal file
37
frontend/src/lib/api/procurement.ts
Normal 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/'),
|
||||
};
|
||||
}
|
||||
42
frontend/src/lib/api/requests.ts
Normal file
42
frontend/src/lib/api/requests.ts
Normal 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 }) }),
|
||||
};
|
||||
}
|
||||
16
frontend/src/lib/api/utils.ts
Normal file
16
frontend/src/lib/api/utils.ts
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user