feat: Initial commit - Task Reporter incident response system
Complete implementation of the production line incident response system (生產線異常即時反應系統) including: Backend (FastAPI): - User authentication with AD integration and session management - Chat room management (create, list, update, members, roles) - Real-time messaging via WebSocket (typing indicators, reactions) - File storage with MinIO (upload, download, image preview) Frontend (React + Vite): - Authentication flow with token management - Room list with filtering, search, and pagination - Real-time chat interface with WebSocket - File upload with drag-and-drop and image preview - Member management and room settings - Breadcrumb navigation - 53 unit tests (Vitest) Specifications: - authentication: AD auth, sessions, JWT tokens - chat-room: rooms, members, templates - realtime-messaging: WebSocket, messages, reactions - file-storage: MinIO integration, file management - frontend-core: React SPA structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
87
frontend/src/App.tsx
Normal file
87
frontend/src/App.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
|
||||
import { useAuthStore } from './stores/authStore'
|
||||
|
||||
// Pages
|
||||
import Login from './pages/Login'
|
||||
import RoomList from './pages/RoomList'
|
||||
import RoomDetail from './pages/RoomDetail'
|
||||
import NotFound from './pages/NotFound'
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Protected route wrapper
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// Public route wrapper (redirect to home if authenticated)
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Login />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<RoomList />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/rooms/:roomId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<RoomDetail />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 404 */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
63
frontend/src/components/common/Breadcrumb.test.tsx
Normal file
63
frontend/src/components/common/Breadcrumb.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { render } from '../../test/test-utils'
|
||||
import { Breadcrumb } from './Breadcrumb'
|
||||
|
||||
describe('Breadcrumb', () => {
|
||||
it('should render single item', () => {
|
||||
render(<Breadcrumb items={[{ label: 'Home' }]} />)
|
||||
|
||||
expect(screen.getByText('Home')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple items with separators', () => {
|
||||
render(
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Rooms', href: '/rooms' },
|
||||
{ label: 'Room 1' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Home')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rooms')).toBeInTheDocument()
|
||||
expect(screen.getByText('Room 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render links for items with href', () => {
|
||||
render(
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Current' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
const homeLink = screen.getByRole('link', { name: 'Home' })
|
||||
expect(homeLink).toHaveAttribute('href', '/')
|
||||
})
|
||||
|
||||
it('should not render link for last item', () => {
|
||||
render(
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Current', href: '/current' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
// Last item should be text, not a link
|
||||
const currentText = screen.getByText('Current')
|
||||
expect(currentText.tagName).not.toBe('A')
|
||||
})
|
||||
|
||||
it('should have aria-label for accessibility', () => {
|
||||
render(<Breadcrumb items={[{ label: 'Home' }]} />)
|
||||
|
||||
expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', 'Breadcrumb')
|
||||
})
|
||||
})
|
||||
54
frontend/src/components/common/Breadcrumb.tsx
Normal file
54
frontend/src/components/common/Breadcrumb.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Link } from 'react-router'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
interface BreadcrumbProps {
|
||||
items: BreadcrumbItem[]
|
||||
}
|
||||
|
||||
export function Breadcrumb({ items }: BreadcrumbProps) {
|
||||
return (
|
||||
<nav className="flex items-center text-sm text-gray-500" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center space-x-2">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1
|
||||
|
||||
return (
|
||||
<li key={index} className="flex items-center">
|
||||
{index > 0 && (
|
||||
<svg
|
||||
className="w-4 h-4 mx-2 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isLast || !item.href ? (
|
||||
<span className={isLast ? 'text-gray-900 font-medium' : ''}>
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to={item.href}
|
||||
className="hover:text-gray-700 hover:underline"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
1
frontend/src/components/common/index.ts
Normal file
1
frontend/src/components/common/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Breadcrumb, type BreadcrumbItem } from './Breadcrumb'
|
||||
33
frontend/src/hooks/index.ts
Normal file
33
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export { useAuth } from './useAuth'
|
||||
export {
|
||||
useRooms,
|
||||
useRoom,
|
||||
useRoomTemplates,
|
||||
useRoomPermissions,
|
||||
useCreateRoom,
|
||||
useUpdateRoom,
|
||||
useDeleteRoom,
|
||||
useAddMember,
|
||||
useUpdateMemberRole,
|
||||
useRemoveMember,
|
||||
useTransferOwnership,
|
||||
roomKeys,
|
||||
} from './useRooms'
|
||||
export {
|
||||
useMessages,
|
||||
useInfiniteMessages,
|
||||
useSearchMessages,
|
||||
useCreateMessage,
|
||||
useOnlineUsers,
|
||||
useTypingUsers,
|
||||
messageKeys,
|
||||
} from './useMessages'
|
||||
export { useWebSocket } from './useWebSocket'
|
||||
export {
|
||||
useFiles,
|
||||
useFile,
|
||||
useUploadFile,
|
||||
useDeleteFile,
|
||||
useDownloadFile,
|
||||
fileKeys,
|
||||
} from './useFiles'
|
||||
154
frontend/src/hooks/useAuth.test.ts
Normal file
154
frontend/src/hooks/useAuth.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useAuth } from './useAuth'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { createWrapper } from '../test/test-utils'
|
||||
|
||||
// Mock react-router
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('react-router', async () => {
|
||||
const actual = await vi.importActual('react-router')
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock authService
|
||||
vi.mock('../services/auth', () => ({
|
||||
authService: {
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { authService } from '../services/auth'
|
||||
|
||||
describe('useAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset auth store
|
||||
useAuthStore.setState({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should return initial unauthenticated state', () => {
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(result.current.token).toBeNull()
|
||||
expect(result.current.user).toBeNull()
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
expect(result.current.isLoggingIn).toBe(false)
|
||||
expect(result.current.isLoggingOut).toBe(false)
|
||||
})
|
||||
|
||||
it('should return authenticated state when user is logged in', () => {
|
||||
useAuthStore.setState({
|
||||
token: 'test-token',
|
||||
user: { username: 'testuser', display_name: 'Test User' },
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(result.current.token).toBe('test-token')
|
||||
expect(result.current.user?.username).toBe('testuser')
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('should login successfully and navigate to home', async () => {
|
||||
vi.mocked(authService.login).mockResolvedValue({
|
||||
token: 'new-token',
|
||||
display_name: 'Test User',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await result.current.login({ username: 'testuser', password: 'password' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
expect(authService.login).toHaveBeenCalledWith({
|
||||
username: 'testuser',
|
||||
password: 'password',
|
||||
})
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/')
|
||||
})
|
||||
|
||||
it('should handle login error', async () => {
|
||||
const loginError = new Error('Invalid credentials')
|
||||
vi.mocked(authService.login).mockRejectedValue(loginError)
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await expect(
|
||||
result.current.login({ username: 'testuser', password: 'wrong' })
|
||||
).rejects.toThrow('Invalid credentials')
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('should logout and navigate to login page', async () => {
|
||||
vi.mocked(authService.logout).mockResolvedValue(undefined)
|
||||
|
||||
// Set initial authenticated state
|
||||
useAuthStore.setState({
|
||||
token: 'test-token',
|
||||
user: { username: 'testuser', display_name: 'Test User' },
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
result.current.logout()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
|
||||
it('should clear auth even if logout API fails', async () => {
|
||||
vi.mocked(authService.logout).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
useAuthStore.setState({
|
||||
token: 'test-token',
|
||||
user: { username: 'testuser', display_name: 'Test User' },
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAuth(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
result.current.logout()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
})
|
||||
48
frontend/src/hooks/useAuth.ts
Normal file
48
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { authService } from '../services/auth'
|
||||
import type { LoginRequest } from '../types'
|
||||
|
||||
export function useAuth() {
|
||||
const navigate = useNavigate()
|
||||
const { token, user, isAuthenticated, setAuth, clearAuth } = useAuthStore()
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (credentials: LoginRequest) => {
|
||||
const data = await authService.login(credentials)
|
||||
return { ...data, username: credentials.username }
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setAuth(data.token, data.display_name, data.username)
|
||||
navigate('/')
|
||||
},
|
||||
})
|
||||
|
||||
const login = (credentials: LoginRequest) => {
|
||||
return loginMutation.mutateAsync(credentials)
|
||||
}
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: () => authService.logout(),
|
||||
onSettled: () => {
|
||||
clearAuth()
|
||||
navigate('/login')
|
||||
},
|
||||
})
|
||||
|
||||
const logout = () => {
|
||||
logoutMutation.mutate()
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
isLoggingIn: loginMutation.isPending,
|
||||
isLoggingOut: logoutMutation.isPending,
|
||||
loginError: loginMutation.error,
|
||||
}
|
||||
}
|
||||
63
frontend/src/hooks/useFiles.ts
Normal file
63
frontend/src/hooks/useFiles.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { filesService, type FileFilters } from '../services/files'
|
||||
|
||||
// Query keys
|
||||
export const fileKeys = {
|
||||
all: ['files'] as const,
|
||||
lists: () => [...fileKeys.all, 'list'] as const,
|
||||
list: (roomId: string, filters?: FileFilters) => [...fileKeys.lists(), roomId, filters] as const,
|
||||
details: () => [...fileKeys.all, 'detail'] as const,
|
||||
detail: (roomId: string, fileId: string) => [...fileKeys.details(), roomId, fileId] as const,
|
||||
}
|
||||
|
||||
export function useFiles(roomId: string, filters?: FileFilters) {
|
||||
return useQuery({
|
||||
queryKey: fileKeys.list(roomId, filters),
|
||||
queryFn: () => filesService.listFiles(roomId, filters),
|
||||
enabled: !!roomId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useFile(roomId: string, fileId: string) {
|
||||
return useQuery({
|
||||
queryKey: fileKeys.detail(roomId, fileId),
|
||||
queryFn: () => filesService.getFile(roomId, fileId),
|
||||
enabled: !!roomId && !!fileId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUploadFile(roomId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
file,
|
||||
description,
|
||||
onProgress,
|
||||
}: {
|
||||
file: File
|
||||
description?: string
|
||||
onProgress?: (progress: number) => void
|
||||
}) => filesService.uploadFile(roomId, file, description, onProgress),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: fileKeys.list(roomId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteFile(roomId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (fileId: string) => filesService.deleteFile(roomId, fileId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: fileKeys.list(roomId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDownloadFile(roomId: string) {
|
||||
return useMutation({
|
||||
mutationFn: (fileId: string) => filesService.downloadFile(roomId, fileId),
|
||||
})
|
||||
}
|
||||
80
frontend/src/hooks/useMessages.ts
Normal file
80
frontend/src/hooks/useMessages.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { messagesService, type MessageFilters } from '../services/messages'
|
||||
import type { CreateMessageRequest } from '../types'
|
||||
|
||||
// Query keys
|
||||
export const messageKeys = {
|
||||
all: ['messages'] as const,
|
||||
lists: () => [...messageKeys.all, 'list'] as const,
|
||||
list: (roomId: string, filters?: MessageFilters) => [...messageKeys.lists(), roomId, filters] as const,
|
||||
infinite: (roomId: string) => [...messageKeys.all, 'infinite', roomId] as const,
|
||||
search: (roomId: string, query: string) => [...messageKeys.all, 'search', roomId, query] as const,
|
||||
online: (roomId: string) => [...messageKeys.all, 'online', roomId] as const,
|
||||
typing: (roomId: string) => [...messageKeys.all, 'typing', roomId] as const,
|
||||
}
|
||||
|
||||
export function useMessages(roomId: string, filters?: MessageFilters) {
|
||||
return useQuery({
|
||||
queryKey: messageKeys.list(roomId, filters),
|
||||
queryFn: () => messagesService.getMessages(roomId, filters),
|
||||
enabled: !!roomId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useInfiniteMessages(roomId: string, limit = 50) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: messageKeys.infinite(roomId),
|
||||
queryFn: ({ pageParam }) =>
|
||||
messagesService.getMessages(roomId, {
|
||||
limit,
|
||||
before: pageParam as string | undefined,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (!lastPage.has_more || lastPage.messages.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
// Get the oldest message timestamp for pagination
|
||||
const oldestMessage = lastPage.messages[0]
|
||||
return oldestMessage?.created_at
|
||||
},
|
||||
enabled: !!roomId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSearchMessages(roomId: string, query: string) {
|
||||
return useQuery({
|
||||
queryKey: messageKeys.search(roomId, query),
|
||||
queryFn: () => messagesService.searchMessages(roomId, query),
|
||||
enabled: !!roomId && query.length > 0,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateMessage(roomId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateMessageRequest) => messagesService.createMessage(roomId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: messageKeys.list(roomId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useOnlineUsers(roomId: string) {
|
||||
return useQuery({
|
||||
queryKey: messageKeys.online(roomId),
|
||||
queryFn: () => messagesService.getOnlineUsers(roomId),
|
||||
enabled: !!roomId,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
})
|
||||
}
|
||||
|
||||
export function useTypingUsers(roomId: string) {
|
||||
return useQuery({
|
||||
queryKey: messageKeys.typing(roomId),
|
||||
queryFn: () => messagesService.getTypingUsers(roomId),
|
||||
enabled: !!roomId,
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
})
|
||||
}
|
||||
152
frontend/src/hooks/useRooms.test.ts
Normal file
152
frontend/src/hooks/useRooms.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useRooms, useRoom, useCreateRoom } from './useRooms'
|
||||
import { createWrapper } from '../test/test-utils'
|
||||
|
||||
// Mock roomsService
|
||||
vi.mock('../services/rooms', () => ({
|
||||
roomsService: {
|
||||
listRooms: vi.fn(),
|
||||
getRoom: vi.fn(),
|
||||
createRoom: vi.fn(),
|
||||
updateRoom: vi.fn(),
|
||||
deleteRoom: vi.fn(),
|
||||
addMember: vi.fn(),
|
||||
updateMemberRole: vi.fn(),
|
||||
removeMember: vi.fn(),
|
||||
transferOwnership: vi.fn(),
|
||||
getPermissions: vi.fn(),
|
||||
getTemplates: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { roomsService } from '../services/rooms'
|
||||
|
||||
const mockRoom = {
|
||||
room_id: 'room-1',
|
||||
title: 'Test Room',
|
||||
incident_type: 'equipment_failure' as const,
|
||||
severity: 'medium' as const,
|
||||
status: 'active' as const,
|
||||
created_by: 'user-1',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
last_activity_at: '2024-01-01T00:00:00Z',
|
||||
last_updated_at: '2024-01-01T00:00:00Z',
|
||||
member_count: 1,
|
||||
}
|
||||
|
||||
describe('useRooms', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useRooms (list)', () => {
|
||||
it('should fetch rooms list', async () => {
|
||||
const mockResponse = {
|
||||
rooms: [mockRoom],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
}
|
||||
vi.mocked(roomsService.listRooms).mockResolvedValue(mockResponse)
|
||||
|
||||
const { result } = renderHook(() => useRooms(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true)
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual(mockResponse)
|
||||
expect(roomsService.listRooms).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should fetch rooms with filters', async () => {
|
||||
const mockResponse = {
|
||||
rooms: [mockRoom],
|
||||
total: 1,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
}
|
||||
vi.mocked(roomsService.listRooms).mockResolvedValue(mockResponse)
|
||||
|
||||
const filters = { status: 'active' as const, limit: 10 }
|
||||
const { result } = renderHook(() => useRooms(filters), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true)
|
||||
})
|
||||
|
||||
expect(roomsService.listRooms).toHaveBeenCalledWith(filters)
|
||||
})
|
||||
|
||||
it('should handle error', async () => {
|
||||
vi.mocked(roomsService.listRooms).mockRejectedValue(new Error('Failed to fetch'))
|
||||
|
||||
const { result } = renderHook(() => useRooms(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRoom (single)', () => {
|
||||
it('should fetch single room by id', async () => {
|
||||
vi.mocked(roomsService.getRoom).mockResolvedValue(mockRoom)
|
||||
|
||||
const { result } = renderHook(() => useRoom('room-1'), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true)
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual(mockRoom)
|
||||
expect(roomsService.getRoom).toHaveBeenCalledWith('room-1')
|
||||
})
|
||||
|
||||
it('should not fetch if roomId is empty', async () => {
|
||||
const { result } = renderHook(() => useRoom(''), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
// Query should be disabled
|
||||
expect(result.current.fetchStatus).toBe('idle')
|
||||
expect(roomsService.getRoom).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCreateRoom', () => {
|
||||
it('should create a new room', async () => {
|
||||
const newRoom = { ...mockRoom, room_id: 'room-new' }
|
||||
vi.mocked(roomsService.createRoom).mockResolvedValue(newRoom)
|
||||
|
||||
const { result } = renderHook(() => useCreateRoom(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
const createData = {
|
||||
title: 'New Room',
|
||||
incident_type: 'equipment_failure' as const,
|
||||
severity: 'medium' as const,
|
||||
}
|
||||
|
||||
result.current.mutate(createData)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true)
|
||||
})
|
||||
|
||||
expect(roomsService.createRoom).toHaveBeenCalledWith(createData)
|
||||
})
|
||||
})
|
||||
})
|
||||
125
frontend/src/hooks/useRooms.ts
Normal file
125
frontend/src/hooks/useRooms.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { roomsService, type RoomFilters } from '../services/rooms'
|
||||
import type { CreateRoomRequest, UpdateRoomRequest, MemberRole } from '../types'
|
||||
|
||||
// Query keys
|
||||
export const roomKeys = {
|
||||
all: ['rooms'] as const,
|
||||
lists: () => [...roomKeys.all, 'list'] as const,
|
||||
list: (filters: RoomFilters) => [...roomKeys.lists(), filters] as const,
|
||||
details: () => [...roomKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...roomKeys.details(), id] as const,
|
||||
templates: () => [...roomKeys.all, 'templates'] as const,
|
||||
permissions: (id: string) => [...roomKeys.all, 'permissions', id] as const,
|
||||
}
|
||||
|
||||
export function useRooms(filters?: RoomFilters) {
|
||||
return useQuery({
|
||||
queryKey: roomKeys.list(filters || {}),
|
||||
queryFn: () => roomsService.listRooms(filters),
|
||||
})
|
||||
}
|
||||
|
||||
export function useRoom(roomId: string) {
|
||||
return useQuery({
|
||||
queryKey: roomKeys.detail(roomId),
|
||||
queryFn: () => roomsService.getRoom(roomId),
|
||||
enabled: !!roomId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useRoomTemplates() {
|
||||
return useQuery({
|
||||
queryKey: roomKeys.templates(),
|
||||
queryFn: () => roomsService.getTemplates(),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
})
|
||||
}
|
||||
|
||||
export function useRoomPermissions(roomId: string) {
|
||||
return useQuery({
|
||||
queryKey: roomKeys.permissions(roomId),
|
||||
queryFn: () => roomsService.getPermissions(roomId),
|
||||
enabled: !!roomId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateRoom() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateRoomRequest) => roomsService.createRoom(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateRoom(roomId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateRoomRequest) => roomsService.updateRoom(roomId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteRoom() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (roomId: string) => roomsService.deleteRoom(roomId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddMember(roomId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, role }: { userId: string; role: MemberRole }) =>
|
||||
roomsService.addMember(roomId, userId, role),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateMemberRole(roomId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, role }: { userId: string; role: MemberRole }) =>
|
||||
roomsService.updateMemberRole(roomId, userId, role),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveMember(roomId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) => roomsService.removeMember(roomId, userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTransferOwnership(roomId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (newOwnerId: string) => roomsService.transferOwnership(roomId, newOwnerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
276
frontend/src/hooks/useWebSocket.ts
Normal file
276
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import type {
|
||||
WebSocketMessageIn,
|
||||
MessageBroadcast,
|
||||
SystemBroadcast,
|
||||
TypingBroadcast,
|
||||
FileUploadedBroadcast,
|
||||
FileDeletedBroadcast,
|
||||
Message,
|
||||
} from '../types'
|
||||
|
||||
const RECONNECT_DELAY = 1000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const RECONNECT_MULTIPLIER = 2
|
||||
|
||||
interface UseWebSocketOptions {
|
||||
onMessage?: (message: Message) => void
|
||||
onFileUploaded?: (data: FileUploadedBroadcast) => void
|
||||
onFileDeleted?: (data: FileDeletedBroadcast) => void
|
||||
}
|
||||
|
||||
export function useWebSocket(roomId: string | null, options?: UseWebSocketOptions) {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<number | null>(null)
|
||||
const reconnectDelayRef = useRef(RECONNECT_DELAY)
|
||||
|
||||
const token = useAuthStore((state) => state.token)
|
||||
const {
|
||||
setConnectionStatus,
|
||||
addMessage,
|
||||
updateMessage,
|
||||
removeMessage,
|
||||
setUserTyping,
|
||||
addOnlineUser,
|
||||
removeOnlineUser,
|
||||
} = useChatStore()
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!roomId || !token) {
|
||||
return
|
||||
}
|
||||
|
||||
// Close existing connection
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/ws/${roomId}?token=${token}`
|
||||
|
||||
setConnectionStatus('connecting')
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnectionStatus('connected')
|
||||
reconnectDelayRef.current = RECONNECT_DELAY
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
handleMessage(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnectionStatus('error')
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnectionStatus('disconnected')
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
wsRef.current = ws
|
||||
}, [roomId, token, setConnectionStatus])
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(data: unknown) => {
|
||||
const msg = data as { type: string }
|
||||
|
||||
switch (msg.type) {
|
||||
case 'message':
|
||||
case 'edit_message': {
|
||||
const messageBroadcast = data as MessageBroadcast
|
||||
const message: Message = {
|
||||
message_id: messageBroadcast.message_id,
|
||||
room_id: messageBroadcast.room_id,
|
||||
sender_id: messageBroadcast.sender_id,
|
||||
content: messageBroadcast.content,
|
||||
message_type: messageBroadcast.message_type,
|
||||
metadata: messageBroadcast.metadata,
|
||||
created_at: messageBroadcast.created_at,
|
||||
edited_at: messageBroadcast.edited_at,
|
||||
sequence_number: messageBroadcast.sequence_number,
|
||||
}
|
||||
|
||||
if (msg.type === 'message') {
|
||||
addMessage(message)
|
||||
options?.onMessage?.(message)
|
||||
} else {
|
||||
updateMessage(message.message_id, message)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'delete_message': {
|
||||
const deleteMsg = data as { message_id: string }
|
||||
removeMessage(deleteMsg.message_id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'typing': {
|
||||
const typingData = data as TypingBroadcast
|
||||
setUserTyping(typingData.user_id, typingData.is_typing)
|
||||
break
|
||||
}
|
||||
|
||||
case 'system': {
|
||||
const systemData = data as SystemBroadcast
|
||||
if (systemData.event === 'user_joined') {
|
||||
addOnlineUser(systemData.user_id || '')
|
||||
} else if (systemData.event === 'user_left') {
|
||||
removeOnlineUser(systemData.user_id || '')
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'file_uploaded': {
|
||||
const fileData = data as FileUploadedBroadcast
|
||||
options?.onFileUploaded?.(fileData)
|
||||
break
|
||||
}
|
||||
|
||||
case 'file_deleted': {
|
||||
const fileData = data as FileDeletedBroadcast
|
||||
options?.onFileDeleted?.(fileData)
|
||||
break
|
||||
}
|
||||
|
||||
case 'ack':
|
||||
// Message acknowledgment - could update optimistic UI
|
||||
break
|
||||
|
||||
case 'error':
|
||||
console.error('WebSocket error message:', data)
|
||||
break
|
||||
|
||||
default:
|
||||
console.log('Unknown WebSocket message type:', msg.type)
|
||||
}
|
||||
},
|
||||
[addMessage, updateMessage, removeMessage, setUserTyping, addOnlineUser, removeOnlineUser, options]
|
||||
)
|
||||
|
||||
const scheduleReconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
reconnectDelayRef.current = Math.min(
|
||||
reconnectDelayRef.current * RECONNECT_MULTIPLIER,
|
||||
MAX_RECONNECT_DELAY
|
||||
)
|
||||
connect()
|
||||
}, reconnectDelayRef.current)
|
||||
}, [connect])
|
||||
|
||||
const sendMessage = useCallback((message: WebSocketMessageIn) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sendTextMessage = useCallback(
|
||||
(content: string, metadata?: Record<string, unknown>) => {
|
||||
sendMessage({
|
||||
type: 'message',
|
||||
content,
|
||||
message_type: 'text',
|
||||
metadata,
|
||||
})
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
const sendTyping = useCallback(
|
||||
(isTyping: boolean) => {
|
||||
sendMessage({
|
||||
type: 'typing',
|
||||
is_typing: isTyping,
|
||||
})
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
const editMessage = useCallback(
|
||||
(messageId: string, content: string) => {
|
||||
sendMessage({
|
||||
type: 'edit_message',
|
||||
message_id: messageId,
|
||||
content,
|
||||
})
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
const deleteMessage = useCallback(
|
||||
(messageId: string) => {
|
||||
sendMessage({
|
||||
type: 'delete_message',
|
||||
message_id: messageId,
|
||||
})
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
const addReaction = useCallback(
|
||||
(messageId: string, emoji: string) => {
|
||||
sendMessage({
|
||||
type: 'add_reaction',
|
||||
message_id: messageId,
|
||||
emoji,
|
||||
})
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
const removeReaction = useCallback(
|
||||
(messageId: string, emoji: string) => {
|
||||
sendMessage({
|
||||
type: 'remove_reaction',
|
||||
message_id: messageId,
|
||||
emoji,
|
||||
})
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Connect when roomId changes
|
||||
useEffect(() => {
|
||||
if (roomId) {
|
||||
connect()
|
||||
}
|
||||
|
||||
return () => {
|
||||
disconnect()
|
||||
}
|
||||
}, [roomId, connect, disconnect])
|
||||
|
||||
return {
|
||||
sendTextMessage,
|
||||
sendTyping,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
disconnect,
|
||||
reconnect: connect,
|
||||
}
|
||||
}
|
||||
17
frontend/src/index.css
Normal file
17
frontend/src/index.css
Normal file
@@ -0,0 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
183
frontend/src/pages/Login.test.tsx
Normal file
183
frontend/src/pages/Login.test.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from '../test/test-utils'
|
||||
import Login from './Login'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
// Mock react-router
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('react-router', async () => {
|
||||
const actual = await vi.importActual('react-router')
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock authService
|
||||
vi.mock('../services/auth', () => ({
|
||||
authService: {
|
||||
login: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { authService } from '../services/auth'
|
||||
|
||||
describe('Login', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAuthStore.setState({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render login form', () => {
|
||||
render(<Login />)
|
||||
|
||||
expect(screen.getByText('Task Reporter')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have empty inputs initially', () => {
|
||||
render(<Login />)
|
||||
|
||||
expect(screen.getByLabelText(/email/i)).toHaveValue('')
|
||||
expect(screen.getByLabelText(/password/i)).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('form interaction', () => {
|
||||
it('should update input values when typing', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i)
|
||||
const passwordInput = screen.getByLabelText(/password/i)
|
||||
|
||||
await user.type(emailInput, 'test@example.com')
|
||||
await user.type(passwordInput, 'password123')
|
||||
|
||||
expect(emailInput).toHaveValue('test@example.com')
|
||||
expect(passwordInput).toHaveValue('password123')
|
||||
})
|
||||
|
||||
it('should submit form with credentials', async () => {
|
||||
vi.mocked(authService.login).mockResolvedValue({
|
||||
token: 'test-token',
|
||||
display_name: 'Test User',
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<Login />)
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: /login/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authService.login).toHaveBeenCalledWith({
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should navigate to home on successful login', async () => {
|
||||
vi.mocked(authService.login).mockResolvedValue({
|
||||
token: 'test-token',
|
||||
display_name: 'Test User',
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<Login />)
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: /login/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/')
|
||||
})
|
||||
})
|
||||
|
||||
it('should update auth store on successful login', async () => {
|
||||
vi.mocked(authService.login).mockResolvedValue({
|
||||
token: 'test-token',
|
||||
display_name: 'Test User',
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<Login />)
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: /login/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.token).toBe('test-token')
|
||||
expect(state.isAuthenticated).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should display error message on login failure', async () => {
|
||||
vi.mocked(authService.login).mockRejectedValue(new Error('Invalid credentials'))
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<Login />)
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
||||
await user.type(screen.getByLabelText(/password/i), 'wrongpassword')
|
||||
await user.click(screen.getByRole('button', { name: /login/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('should show loading state during login', async () => {
|
||||
// Make login hang to test loading state
|
||||
vi.mocked(authService.login).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<Login />)
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: /login/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/logging in/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable button during login', async () => {
|
||||
vi.mocked(authService.login).mockImplementation(
|
||||
() => new Promise(() => {})
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<Login />)
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), 'testuser')
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: /login/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
136
frontend/src/pages/Login.tsx
Normal file
136
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { authService } from '../services/auth'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: () => authService.login({ username, password }),
|
||||
onSuccess: (data) => {
|
||||
setAuth(data.token, data.display_name, username)
|
||||
navigate('/')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
loginMutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="max-w-md w-full mx-4">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Task Reporter
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Production Line Incident Response System
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{loginMutation.isError && (
|
||||
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
|
||||
{loginMutation.error instanceof Error
|
||||
? 'Invalid credentials. Please try again.'
|
||||
: 'An error occurred. Please try again.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg font-medium hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loginMutation.isPending ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Logging in...
|
||||
</span>
|
||||
) : (
|
||||
'Login'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-gray-500 text-sm mt-6">
|
||||
Use your company credentials to login
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
frontend/src/pages/NotFound.tsx
Normal file
21
frontend/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Link } from 'react-router'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold text-gray-300 mb-4">404</h1>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-2">Page Not Found</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Go to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
852
frontend/src/pages/RoomDetail.tsx
Normal file
852
frontend/src/pages/RoomDetail.tsx
Normal file
@@ -0,0 +1,852 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useParams, Link } from 'react-router'
|
||||
import {
|
||||
useRoom,
|
||||
useRoomPermissions,
|
||||
useUpdateRoom,
|
||||
useAddMember,
|
||||
useUpdateMemberRole,
|
||||
useRemoveMember,
|
||||
} from '../hooks/useRooms'
|
||||
import { useMessages } from '../hooks/useMessages'
|
||||
import { useWebSocket } from '../hooks/useWebSocket'
|
||||
import { useFiles, useUploadFile, useDeleteFile } from '../hooks/useFiles'
|
||||
import { filesService } from '../services/files'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { Breadcrumb } from '../components/common'
|
||||
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata } from '../types'
|
||||
|
||||
const statusColors: Record<RoomStatus, string> = {
|
||||
active: 'bg-green-100 text-green-800',
|
||||
resolved: 'bg-blue-100 text-blue-800',
|
||||
archived: 'bg-gray-100 text-gray-800',
|
||||
}
|
||||
|
||||
const severityColors: Record<SeverityLevel, string> = {
|
||||
low: 'bg-gray-100 text-gray-800',
|
||||
medium: 'bg-yellow-100 text-yellow-800',
|
||||
high: 'bg-orange-100 text-orange-800',
|
||||
critical: 'bg-red-100 text-red-800',
|
||||
}
|
||||
|
||||
const roleLabels: Record<MemberRole, string> = {
|
||||
owner: 'Owner',
|
||||
editor: 'Editor',
|
||||
viewer: 'Viewer',
|
||||
}
|
||||
|
||||
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉']
|
||||
|
||||
export default function RoomDetail() {
|
||||
const { roomId } = useParams<{ roomId: string }>()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
|
||||
const { data: room, isLoading: roomLoading, error: roomError } = useRoom(roomId || '')
|
||||
const { data: permissions } = useRoomPermissions(roomId || '')
|
||||
const { data: messagesData, isLoading: messagesLoading } = useMessages(roomId || '', { limit: 50 })
|
||||
|
||||
const { messages, connectionStatus, typingUsers, onlineUsers, setMessages, setCurrentRoom } = useChatStore()
|
||||
const { sendTextMessage, sendTyping, editMessage, deleteMessage, addReaction, removeReaction } = useWebSocket(roomId || null)
|
||||
|
||||
// Mutations
|
||||
const updateRoom = useUpdateRoom(roomId || '')
|
||||
const addMember = useAddMember(roomId || '')
|
||||
const updateMemberRole = useUpdateMemberRole(roomId || '')
|
||||
const removeMember = useRemoveMember(roomId || '')
|
||||
|
||||
// File hooks
|
||||
const { data: filesData, isLoading: filesLoading } = useFiles(roomId || '')
|
||||
const uploadFile = useUploadFile(roomId || '')
|
||||
const deleteFile = useDeleteFile(roomId || '')
|
||||
|
||||
const [messageInput, setMessageInput] = useState('')
|
||||
const [showMembers, setShowMembers] = useState(false)
|
||||
const [showFiles, setShowFiles] = useState(false)
|
||||
const [showAddMember, setShowAddMember] = useState(false)
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [showEmojiPickerFor, setShowEmojiPickerFor] = useState<string | null>(null)
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [previewFile, setPreviewFile] = useState<FileMetadata | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [newMemberUsername, setNewMemberUsername] = useState('')
|
||||
const [newMemberRole, setNewMemberRole] = useState<MemberRole>('viewer')
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const typingTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
// Initialize room
|
||||
useEffect(() => {
|
||||
if (roomId) {
|
||||
setCurrentRoom(roomId)
|
||||
}
|
||||
return () => {
|
||||
setCurrentRoom(null)
|
||||
}
|
||||
}, [roomId, setCurrentRoom])
|
||||
|
||||
// Load initial messages
|
||||
useEffect(() => {
|
||||
if (messagesData?.messages) {
|
||||
setMessages(messagesData.messages)
|
||||
}
|
||||
}, [messagesData, setMessages])
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// Handle typing indicator
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setMessageInput(e.target.value)
|
||||
|
||||
// Send typing indicator
|
||||
sendTyping(true)
|
||||
|
||||
// Clear previous timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Stop typing after 2 seconds of inactivity
|
||||
typingTimeoutRef.current = window.setTimeout(() => {
|
||||
sendTyping(false)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const handleSendMessage = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!messageInput.trim()) return
|
||||
|
||||
sendTextMessage(messageInput.trim())
|
||||
setMessageInput('')
|
||||
sendTyping(false)
|
||||
}
|
||||
|
||||
const handleStartEdit = (messageId: string, content: string) => {
|
||||
setEditingMessageId(messageId)
|
||||
setEditContent(content)
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingMessageId(null)
|
||||
setEditContent('')
|
||||
}
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!editingMessageId || !editContent.trim()) return
|
||||
editMessage(editingMessageId, editContent.trim())
|
||||
setEditingMessageId(null)
|
||||
setEditContent('')
|
||||
}
|
||||
|
||||
const handleDeleteMessage = (messageId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this message?')) {
|
||||
deleteMessage(messageId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddReaction = (messageId: string, emoji: string) => {
|
||||
addReaction(messageId, emoji)
|
||||
setShowEmojiPickerFor(null)
|
||||
}
|
||||
|
||||
const handleRemoveReaction = (messageId: string, emoji: string) => {
|
||||
removeReaction(messageId, emoji)
|
||||
}
|
||||
|
||||
const handleStatusChange = (newStatus: RoomStatus) => {
|
||||
if (window.confirm(`Are you sure you want to change the room status to "${newStatus}"?`)) {
|
||||
updateRoom.mutate({ status: newStatus })
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddMember = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newMemberUsername.trim()) return
|
||||
|
||||
addMember.mutate(
|
||||
{ userId: newMemberUsername.trim(), role: newMemberRole },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setNewMemberUsername('')
|
||||
setNewMemberRole('viewer')
|
||||
setShowAddMember(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleRoleChange = (userId: string, newRole: MemberRole) => {
|
||||
updateMemberRole.mutate({ userId, role: newRole })
|
||||
}
|
||||
|
||||
const handleRemoveMember = (userId: string) => {
|
||||
if (window.confirm(`Are you sure you want to remove this member?`)) {
|
||||
removeMember.mutate(userId)
|
||||
}
|
||||
}
|
||||
|
||||
// File handlers
|
||||
const handleFileUpload = useCallback(
|
||||
(files: FileList | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const file = files[0]
|
||||
setUploadProgress(0)
|
||||
|
||||
uploadFile.mutate(
|
||||
{
|
||||
file,
|
||||
onProgress: (progress) => setUploadProgress(progress),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUploadProgress(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setUploadProgress(null)
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[uploadFile]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
handleFileUpload(e.dataTransfer.files)
|
||||
},
|
||||
[handleFileUpload]
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDeleteFile = (fileId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this file?')) {
|
||||
deleteFile.mutate(fileId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadFile = async (file: FileMetadata) => {
|
||||
if (file.download_url) {
|
||||
window.open(file.download_url, '_blank')
|
||||
} else if (roomId) {
|
||||
await filesService.downloadFile(roomId, file.file_id)
|
||||
}
|
||||
}
|
||||
|
||||
if (roomLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (roomError || !room) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Failed to load room</p>
|
||||
<Link to="/" className="text-blue-600 hover:text-blue-700">
|
||||
Back to Room List
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const typingUsersArray = Array.from(typingUsers).filter((u) => u !== user?.username)
|
||||
const onlineUsersArray = Array.from(onlineUsers)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm flex-shrink-0">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-2">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Rooms', href: '/' },
|
||||
{ label: room.title },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h1 className="font-semibold text-gray-900">{room.title}</h1>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColors[room.status]}`}>
|
||||
{room.status}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${severityColors[room.severity]}`}>
|
||||
{room.severity}
|
||||
</span>
|
||||
{room.location && <span className="text-gray-500">{room.location}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Connection Status */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
connectionStatus === 'connected'
|
||||
? 'bg-green-500'
|
||||
: connectionStatus === 'connecting'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">
|
||||
{connectionStatus === 'connected' ? 'Connected' : connectionStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status Actions (Owner only) */}
|
||||
{permissions?.can_update_status && room.status === 'active' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleStatusChange('resolved')}
|
||||
className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatusChange('archived')}
|
||||
className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files Toggle */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFiles(!showFiles)
|
||||
setShowMembers(false)
|
||||
}}
|
||||
className={`flex items-center gap-1 ${showFiles ? 'text-blue-600' : 'text-gray-600 hover:text-gray-800'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm">Files</span>
|
||||
</button>
|
||||
|
||||
{/* Members Toggle */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMembers(!showMembers)
|
||||
setShowFiles(false)
|
||||
}}
|
||||
className={`flex items-center gap-1 ${showMembers ? 'text-blue-600' : 'text-gray-600 hover:text-gray-800'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm">{room.member_count}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messagesLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No messages yet. Start the conversation!
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => {
|
||||
const isOwnMessage = message.sender_id === user?.username
|
||||
const isEditing = editingMessageId === message.message_id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.message_id}
|
||||
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'} group`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[70%] rounded-lg px-4 py-2 ${
|
||||
isOwnMessage
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{!isOwnMessage && (
|
||||
<div className="text-xs font-medium text-gray-500 mb-1">
|
||||
{message.sender_id}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm text-gray-900 bg-white border border-gray-300 rounded"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveEdit()
|
||||
if (e.key === 'Escape') handleCancelEdit()
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
className="px-2 py-1 bg-green-500 text-white rounded hover:bg-green-600"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className={isOwnMessage ? 'text-white' : 'text-gray-900'}>
|
||||
{message.content}
|
||||
</p>
|
||||
|
||||
{/* Reactions Display */}
|
||||
{message.reaction_counts && Object.keys(message.reaction_counts).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{Object.entries(message.reaction_counts).map(([emoji, count]) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => handleRemoveReaction(message.message_id, emoji)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs ${
|
||||
isOwnMessage
|
||||
? 'bg-blue-500 hover:bg-blue-400'
|
||||
: 'bg-gray-100 hover:bg-gray-200'
|
||||
}`}
|
||||
title={`Remove ${emoji}`}
|
||||
>
|
||||
<span>{emoji}</span>
|
||||
<span className={isOwnMessage ? 'text-blue-100' : 'text-gray-600'}>{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div
|
||||
className={`text-xs mt-1 ${
|
||||
isOwnMessage ? 'text-blue-200' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{new Date(message.created_at).toLocaleTimeString()}
|
||||
{message.edited_at && ' (edited)'}
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{/* Reaction Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowEmojiPickerFor(
|
||||
showEmojiPickerFor === message.message_id ? null : message.message_id
|
||||
)}
|
||||
className={`p-1 ${isOwnMessage ? 'text-blue-200 hover:text-white' : 'text-gray-400 hover:text-gray-600'}`}
|
||||
title="Add reaction"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Emoji Picker Dropdown */}
|
||||
{showEmojiPickerFor === message.message_id && (
|
||||
<div className={`absolute bottom-full mb-1 ${isOwnMessage ? 'right-0' : 'left-0'} bg-white shadow-lg rounded-lg p-2 z-10 flex gap-1`}>
|
||||
{QUICK_EMOJIS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => handleAddReaction(message.message_id, emoji)}
|
||||
className="w-7 h-7 flex items-center justify-center hover:bg-gray-100 rounded"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Edit/Delete (own messages only) */}
|
||||
{isOwnMessage && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleStartEdit(message.message_id, message.content)}
|
||||
className="p-1 text-blue-200 hover:text-white"
|
||||
title="Edit"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteMessage(message.message_id)}
|
||||
className="p-1 text-blue-200 hover:text-red-300"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Typing Indicator */}
|
||||
{typingUsersArray.length > 0 && (
|
||||
<div className="px-4 py-2 text-sm text-gray-500">
|
||||
{typingUsersArray.join(', ')} {typingUsersArray.length === 1 ? 'is' : 'are'} typing...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Input */}
|
||||
{permissions?.can_write && (
|
||||
<form onSubmit={handleSendMessage} className="p-4 bg-white border-t">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={messageInput}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!messageInput.trim()}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members Sidebar */}
|
||||
{showMembers && (
|
||||
<div className="w-72 bg-white border-l flex-shrink-0 overflow-y-auto">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">Members</h3>
|
||||
{permissions?.can_manage_members && (
|
||||
<button
|
||||
onClick={() => setShowAddMember(!showAddMember)}
|
||||
className="text-blue-600 hover:text-blue-700 text-sm"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Member Form */}
|
||||
{showAddMember && (
|
||||
<form onSubmit={handleAddMember} className="mb-4 p-3 bg-gray-50 rounded-lg">
|
||||
<input
|
||||
type="text"
|
||||
value={newMemberUsername}
|
||||
onChange={(e) => setNewMemberUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={newMemberRole}
|
||||
onChange={(e) => setNewMemberRole(e.target.value as MemberRole)}
|
||||
className="flex-1 px-2 py-1.5 text-sm border border-gray-300 rounded"
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addMember.isPending}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{addMember.isError && (
|
||||
<p className="text-xs text-red-500 mt-1">Failed to add member</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Member List */}
|
||||
<div className="space-y-2">
|
||||
{room.members?.map((member) => (
|
||||
<div
|
||||
key={member.user_id}
|
||||
className="flex items-center justify-between py-2 px-2 hover:bg-gray-50 rounded"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
onlineUsersArray.includes(member.user_id) ? 'bg-green-500' : 'bg-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-900 truncate">{member.user_id}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Role selector (Owner can change roles except their own) */}
|
||||
{permissions?.role === 'owner' && member.role !== 'owner' ? (
|
||||
<select
|
||||
value={member.role}
|
||||
onChange={(e) => handleRoleChange(member.user_id, e.target.value as MemberRole)}
|
||||
className="text-xs px-1 py-0.5 border border-gray-200 rounded"
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500 px-1">{roleLabels[member.role]}</span>
|
||||
)}
|
||||
{/* Remove button (Owner/Editor can remove, but not the owner) */}
|
||||
{permissions?.can_manage_members &&
|
||||
member.role !== 'owner' &&
|
||||
member.user_id !== user?.username && (
|
||||
<button
|
||||
onClick={() => handleRemoveMember(member.user_id)}
|
||||
className="p-1 text-gray-400 hover:text-red-500"
|
||||
title="Remove member"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files Sidebar */}
|
||||
{showFiles && (
|
||||
<div className="w-80 bg-white border-l flex-shrink-0 overflow-y-auto">
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Files</h3>
|
||||
|
||||
{/* Upload Area */}
|
||||
{permissions?.can_write && (
|
||||
<div
|
||||
className={`mb-4 border-2 border-dashed rounded-lg p-4 text-center transition-colors ${
|
||||
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
|
||||
}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
{uploadProgress !== null ? (
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-2">Uploading...</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{uploadProgress}%</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-8 h-8 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="text-sm text-gray-600">
|
||||
Drag & drop or{' '}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
browse
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File List */}
|
||||
{filesLoading ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
|
||||
</div>
|
||||
) : filesData?.files.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">No files uploaded yet</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filesData?.files.map((file) => (
|
||||
<div
|
||||
key={file.file_id}
|
||||
className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded"
|
||||
>
|
||||
{/* Thumbnail or Icon */}
|
||||
<div className="w-10 h-10 flex-shrink-0 rounded bg-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{filesService.isImage(file.mime_type) ? (
|
||||
<svg className="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-900 truncate">{file.filename}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{filesService.formatFileSize(file.file_size)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Preview button for images */}
|
||||
{filesService.isImage(file.mime_type) && (
|
||||
<button
|
||||
onClick={() => setPreviewFile(file)}
|
||||
className="p-1 text-gray-400 hover:text-blue-500"
|
||||
title="Preview"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{/* Download button */}
|
||||
<button
|
||||
onClick={() => handleDownloadFile(file)}
|
||||
className="p-1 text-gray-400 hover:text-blue-500"
|
||||
title="Download"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Delete button */}
|
||||
{(file.uploader_id === user?.username || permissions?.can_delete) && (
|
||||
<button
|
||||
onClick={() => handleDeleteFile(file.file_id)}
|
||||
className="p-1 text-gray-400 hover:text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Preview Modal */}
|
||||
{previewFile && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"
|
||||
onClick={() => setPreviewFile(null)}
|
||||
>
|
||||
<div className="relative max-w-4xl max-h-[90vh] m-4">
|
||||
<button
|
||||
onClick={() => setPreviewFile(null)}
|
||||
className="absolute -top-10 right-0 text-white hover:text-gray-300"
|
||||
>
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<img
|
||||
src={previewFile.download_url || ''}
|
||||
alt={previewFile.filename}
|
||||
className="max-w-full max-h-[85vh] object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="absolute -bottom-10 left-0 right-0 flex justify-center gap-4">
|
||||
<span className="text-white text-sm">{previewFile.filename}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDownloadFile(previewFile)
|
||||
}}
|
||||
className="text-white hover:text-blue-400 text-sm"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
376
frontend/src/pages/RoomList.tsx
Normal file
376
frontend/src/pages/RoomList.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { useRooms, useCreateRoom, useRoomTemplates } from '../hooks/useRooms'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { Breadcrumb } from '../components/common'
|
||||
import type { RoomStatus, IncidentType, SeverityLevel, CreateRoomRequest } from '../types'
|
||||
|
||||
const statusColors: Record<RoomStatus, string> = {
|
||||
active: 'bg-green-100 text-green-800',
|
||||
resolved: 'bg-blue-100 text-blue-800',
|
||||
archived: 'bg-gray-100 text-gray-800',
|
||||
}
|
||||
|
||||
const severityColors: Record<SeverityLevel, string> = {
|
||||
low: 'bg-gray-100 text-gray-800',
|
||||
medium: 'bg-yellow-100 text-yellow-800',
|
||||
high: 'bg-orange-100 text-orange-800',
|
||||
critical: 'bg-red-100 text-red-800',
|
||||
}
|
||||
|
||||
const incidentTypeLabels: Record<IncidentType, string> = {
|
||||
equipment_failure: 'Equipment Failure',
|
||||
material_shortage: 'Material Shortage',
|
||||
quality_issue: 'Quality Issue',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 12
|
||||
|
||||
export default function RoomList() {
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const clearAuth = useAuthStore((state) => state.clearAuth)
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<RoomStatus | ''>('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// Reset page when filters change
|
||||
const handleStatusChange = (status: RoomStatus | '') => {
|
||||
setStatusFilter(status)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleSearchChange = (searchValue: string) => {
|
||||
setSearch(searchValue)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const { data, isLoading, error } = useRooms({
|
||||
status: statusFilter || undefined,
|
||||
search: search || undefined,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: (page - 1) * ITEMS_PER_PAGE,
|
||||
})
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / ITEMS_PER_PAGE) : 0
|
||||
|
||||
const handleLogout = () => {
|
||||
clearAuth()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<h1 className="text-xl font-bold text-gray-900">Task Reporter</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-600">{user?.display_name}</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4">
|
||||
<Breadcrumb items={[{ label: 'Home', href: '/' }, { label: 'Rooms' }]} />
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search rooms..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => handleStatusChange(e.target.value as RoomStatus | '')}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
|
||||
{/* New Room Button */}
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
+ New Room
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Room List */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-2 text-gray-500">Loading rooms...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500">Failed to load rooms</p>
|
||||
</div>
|
||||
) : data?.rooms.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">No rooms found</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="mt-4 text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Create your first room
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data?.rooms.map((room) => (
|
||||
<Link
|
||||
key={room.room_id}
|
||||
to={`/rooms/${room.room_id}`}
|
||||
className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
{/* Room Header */}
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="font-semibold text-gray-900 truncate flex-1">
|
||||
{room.title}
|
||||
</h3>
|
||||
<span
|
||||
className={`ml-2 px-2 py-0.5 rounded text-xs font-medium ${
|
||||
statusColors[room.status]
|
||||
}`}
|
||||
>
|
||||
{room.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Type and Severity */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<span className="text-xs text-gray-500">
|
||||
{incidentTypeLabels[room.incident_type]}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
severityColors[room.severity]
|
||||
}`}
|
||||
>
|
||||
{room.severity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{room.description && (
|
||||
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
|
||||
{room.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-between items-center text-xs text-gray-400">
|
||||
<span>{room.member_count} members</span>
|
||||
<span>
|
||||
{new Date(room.last_activity_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-4 mt-8">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-gray-600">
|
||||
Page {page} of {totalPages}
|
||||
<span className="text-gray-400 ml-2">
|
||||
({data?.total} total)
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Create Room Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateRoomModal onClose={() => setShowCreateModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Create Room Modal Component
|
||||
function CreateRoomModal({ onClose }: { onClose: () => void }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [incidentType, setIncidentType] = useState<IncidentType>('equipment_failure')
|
||||
const [severity, setSeverity] = useState<SeverityLevel>('medium')
|
||||
const [description, setDescription] = useState('')
|
||||
const [location, setLocation] = useState('')
|
||||
|
||||
const createRoom = useCreateRoom()
|
||||
// Templates loaded for future use (template selection feature)
|
||||
useRoomTemplates()
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const data: CreateRoomRequest = {
|
||||
title,
|
||||
incident_type: incidentType,
|
||||
severity,
|
||||
description: description || undefined,
|
||||
location: location || undefined,
|
||||
}
|
||||
|
||||
createRoom.mutate(data, {
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Create New Room</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Incident Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Incident Type *
|
||||
</label>
|
||||
<select
|
||||
value={incidentType}
|
||||
onChange={(e) => setIncidentType(e.target.value as IncidentType)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
>
|
||||
<option value="equipment_failure">Equipment Failure</option>
|
||||
<option value="material_shortage">Material Shortage</option>
|
||||
<option value="quality_issue">Quality Issue</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Severity */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Severity *
|
||||
</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={(e) => setSeverity(e.target.value as SeverityLevel)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Location
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
placeholder="e.g., Line A, Station 3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
||||
rows={3}
|
||||
placeholder="Describe the incident..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{createRoom.isError && (
|
||||
<div className="bg-red-50 text-red-600 px-3 py-2 rounded-lg text-sm">
|
||||
Failed to create room. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createRoom.isPending}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{createRoom.isPending ? 'Creating...' : 'Create Room'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
frontend/src/pages/index.ts
Normal file
4
frontend/src/pages/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Login } from './Login'
|
||||
export { default as RoomList } from './RoomList'
|
||||
export { default as RoomDetail } from './RoomDetail'
|
||||
export { default as NotFound } from './NotFound'
|
||||
46
frontend/src/services/api.ts
Normal file
46
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
// Create axios instance
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add auth token
|
||||
api.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor - handle errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token expired or invalid - clear storage and redirect to login
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
|
||||
// Only redirect if not already on login page
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
93
frontend/src/services/auth.test.ts
Normal file
93
frontend/src/services/auth.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { authService } from './auth'
|
||||
import api from './api'
|
||||
|
||||
// Mock the api module
|
||||
vi.mock('./api', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('authService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('should call api.post with correct params', async () => {
|
||||
const mockResponse = { data: { token: 'test-token', display_name: 'Test User' } }
|
||||
vi.mocked(api.post).mockResolvedValue(mockResponse)
|
||||
|
||||
const credentials = { username: 'testuser', password: 'password123' }
|
||||
const result = await authService.login(credentials)
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/login', credentials)
|
||||
expect(result).toEqual(mockResponse.data)
|
||||
})
|
||||
|
||||
it('should throw error on failed login', async () => {
|
||||
vi.mocked(api.post).mockRejectedValue(new Error('Invalid credentials'))
|
||||
|
||||
await expect(
|
||||
authService.login({ username: 'testuser', password: 'wrongpass' })
|
||||
).rejects.toThrow('Invalid credentials')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('should call api.post to logout endpoint', async () => {
|
||||
vi.mocked(api.post).mockResolvedValue({ data: {} })
|
||||
|
||||
await authService.logout()
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/logout')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getToken', () => {
|
||||
it('should return token from localStorage', () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue('stored-token')
|
||||
|
||||
const token = authService.getToken()
|
||||
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith('token')
|
||||
expect(token).toBe('stored-token')
|
||||
})
|
||||
|
||||
it('should return null if no token stored', () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue(null)
|
||||
|
||||
const token = authService.getToken()
|
||||
|
||||
expect(token).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setAuthData', () => {
|
||||
it('should store token and user in localStorage', () => {
|
||||
authService.setAuthData('new-token', 'Test User')
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('token', 'new-token')
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
'user',
|
||||
JSON.stringify({ display_name: 'Test User' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('should return true if token exists', () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue('test-token')
|
||||
|
||||
expect(authService.isAuthenticated()).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false if no token', () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue(null)
|
||||
|
||||
expect(authService.isAuthenticated()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
62
frontend/src/services/auth.ts
Normal file
62
frontend/src/services/auth.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import api from './api'
|
||||
import type { LoginRequest, LoginResponse } from '../types'
|
||||
|
||||
export const authService = {
|
||||
/**
|
||||
* Login with username and password
|
||||
*/
|
||||
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
const response = await api.post<LoginResponse>('/auth/login', credentials)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout current user
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await api.post('/auth/logout')
|
||||
} finally {
|
||||
// Always clear local storage even if API call fails
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get stored token
|
||||
*/
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem('token')
|
||||
},
|
||||
|
||||
/**
|
||||
* Store auth data
|
||||
*/
|
||||
setAuthData(token: string, displayName: string): void {
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('user', JSON.stringify({ display_name: displayName }))
|
||||
},
|
||||
|
||||
/**
|
||||
* Get stored user data
|
||||
*/
|
||||
getUser(): { display_name: string } | null {
|
||||
const userData = localStorage.getItem('user')
|
||||
if (userData) {
|
||||
try {
|
||||
return JSON.parse(userData)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.getToken()
|
||||
},
|
||||
}
|
||||
114
frontend/src/services/files.ts
Normal file
114
frontend/src/services/files.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import api from './api'
|
||||
import type {
|
||||
FileMetadata,
|
||||
FileListResponse,
|
||||
FileUploadResponse,
|
||||
FileType,
|
||||
} from '../types'
|
||||
|
||||
export interface FileFilters {
|
||||
file_type?: FileType
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export const filesService = {
|
||||
/**
|
||||
* Upload file to room
|
||||
*/
|
||||
async uploadFile(
|
||||
roomId: string,
|
||||
file: File,
|
||||
description?: string,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<FileUploadResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (description) {
|
||||
formData.append('description', description)
|
||||
}
|
||||
|
||||
const response = await api.post<FileUploadResponse>(
|
||||
`/rooms/${roomId}/files`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||
onProgress(progress)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* List files in a room
|
||||
*/
|
||||
async listFiles(roomId: string, filters?: FileFilters): Promise<FileListResponse> {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (filters) {
|
||||
if (filters.file_type) params.append('file_type', filters.file_type)
|
||||
if (filters.limit) params.append('limit', filters.limit.toString())
|
||||
if (filters.offset) params.append('offset', filters.offset.toString())
|
||||
}
|
||||
|
||||
const response = await api.get<FileListResponse>(
|
||||
`/rooms/${roomId}/files?${params.toString()}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get file metadata with download URL
|
||||
*/
|
||||
async getFile(roomId: string, fileId: string): Promise<FileMetadata> {
|
||||
const response = await api.get<FileMetadata>(`/rooms/${roomId}/files/${fileId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete file
|
||||
*/
|
||||
async deleteFile(roomId: string, fileId: string): Promise<void> {
|
||||
await api.delete(`/rooms/${roomId}/files/${fileId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* Download file (opens in new tab or triggers download)
|
||||
*/
|
||||
async downloadFile(roomId: string, fileId: string): Promise<void> {
|
||||
const fileData = await this.getFile(roomId, fileId)
|
||||
if (fileData.download_url) {
|
||||
window.open(fileData.download_url, '_blank')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get file size as human readable string
|
||||
*/
|
||||
formatFileSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let unitIndex = 0
|
||||
let size = bytes
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if file is an image
|
||||
*/
|
||||
isImage(mimeType: string): boolean {
|
||||
return mimeType.startsWith('image/')
|
||||
},
|
||||
}
|
||||
9
frontend/src/services/index.ts
Normal file
9
frontend/src/services/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { default as api } from './api'
|
||||
export { authService } from './auth'
|
||||
export { roomsService } from './rooms'
|
||||
export { messagesService } from './messages'
|
||||
export { filesService } from './files'
|
||||
|
||||
export type { RoomFilters } from './rooms'
|
||||
export type { MessageFilters } from './messages'
|
||||
export type { FileFilters } from './files'
|
||||
77
frontend/src/services/messages.ts
Normal file
77
frontend/src/services/messages.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import api from './api'
|
||||
import type {
|
||||
Message,
|
||||
MessageListResponse,
|
||||
CreateMessageRequest,
|
||||
} from '../types'
|
||||
|
||||
export interface MessageFilters {
|
||||
limit?: number
|
||||
offset?: number
|
||||
before?: string // ISO datetime
|
||||
}
|
||||
|
||||
export const messagesService = {
|
||||
/**
|
||||
* Get messages for a room
|
||||
*/
|
||||
async getMessages(roomId: string, filters?: MessageFilters): Promise<MessageListResponse> {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (filters) {
|
||||
if (filters.limit) params.append('limit', filters.limit.toString())
|
||||
if (filters.offset) params.append('offset', filters.offset.toString())
|
||||
if (filters.before) params.append('before', filters.before)
|
||||
}
|
||||
|
||||
const response = await api.get<MessageListResponse>(
|
||||
`/rooms/${roomId}/messages?${params.toString()}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Create message via REST API
|
||||
*/
|
||||
async createMessage(roomId: string, data: CreateMessageRequest): Promise<Message> {
|
||||
const response = await api.post<Message>(`/rooms/${roomId}/messages`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Search messages in a room
|
||||
*/
|
||||
async searchMessages(
|
||||
roomId: string,
|
||||
query: string,
|
||||
limit = 50,
|
||||
offset = 0
|
||||
): Promise<MessageListResponse> {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
limit: limit.toString(),
|
||||
offset: offset.toString(),
|
||||
})
|
||||
|
||||
const response = await api.get<MessageListResponse>(
|
||||
`/rooms/${roomId}/messages/search?${params.toString()}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get online users in a room
|
||||
*/
|
||||
async getOnlineUsers(roomId: string): Promise<{ room_id: string; online_users: string[]; count: number }> {
|
||||
const response = await api.get(`/rooms/${roomId}/online`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get typing users in a room
|
||||
*/
|
||||
async getTypingUsers(roomId: string): Promise<{ room_id: string; typing_users: string[]; count: number }> {
|
||||
const response = await api.get(`/rooms/${roomId}/typing`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
136
frontend/src/services/rooms.ts
Normal file
136
frontend/src/services/rooms.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import api from './api'
|
||||
import type {
|
||||
Room,
|
||||
RoomListResponse,
|
||||
CreateRoomRequest,
|
||||
UpdateRoomRequest,
|
||||
RoomMember,
|
||||
RoomTemplate,
|
||||
PermissionResponse,
|
||||
MemberRole,
|
||||
RoomStatus,
|
||||
IncidentType,
|
||||
SeverityLevel,
|
||||
} from '../types'
|
||||
|
||||
export interface RoomFilters {
|
||||
status?: RoomStatus
|
||||
incident_type?: IncidentType
|
||||
severity?: SeverityLevel
|
||||
search?: string
|
||||
all?: boolean
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export const roomsService = {
|
||||
/**
|
||||
* Get list of rooms
|
||||
*/
|
||||
async listRooms(filters?: RoomFilters): Promise<RoomListResponse> {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (filters) {
|
||||
if (filters.status) params.append('status', filters.status)
|
||||
if (filters.incident_type) params.append('incident_type', filters.incident_type)
|
||||
if (filters.severity) params.append('severity', filters.severity)
|
||||
if (filters.search) params.append('search', filters.search)
|
||||
if (filters.all) params.append('all', 'true')
|
||||
if (filters.limit) params.append('limit', filters.limit.toString())
|
||||
if (filters.offset) params.append('offset', filters.offset.toString())
|
||||
}
|
||||
|
||||
const response = await api.get<RoomListResponse>(`/rooms?${params.toString()}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get single room details
|
||||
*/
|
||||
async getRoom(roomId: string): Promise<Room> {
|
||||
const response = await api.get<Room>(`/rooms/${roomId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Create new room
|
||||
*/
|
||||
async createRoom(data: CreateRoomRequest): Promise<Room> {
|
||||
const response = await api.post<Room>('/rooms', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Update room
|
||||
*/
|
||||
async updateRoom(roomId: string, data: UpdateRoomRequest): Promise<Room> {
|
||||
const response = await api.patch<Room>(`/rooms/${roomId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete (archive) room
|
||||
*/
|
||||
async deleteRoom(roomId: string): Promise<void> {
|
||||
await api.delete(`/rooms/${roomId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get room members
|
||||
*/
|
||||
async getMembers(roomId: string): Promise<RoomMember[]> {
|
||||
const response = await api.get<RoomMember[]>(`/rooms/${roomId}/members`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Add member to room
|
||||
*/
|
||||
async addMember(roomId: string, userId: string, role: MemberRole): Promise<RoomMember> {
|
||||
const response = await api.post<RoomMember>(`/rooms/${roomId}/members`, {
|
||||
user_id: userId,
|
||||
role,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Update member role
|
||||
*/
|
||||
async updateMemberRole(roomId: string, userId: string, role: MemberRole): Promise<RoomMember> {
|
||||
const response = await api.patch<RoomMember>(`/rooms/${roomId}/members/${userId}`, { role })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove member from room
|
||||
*/
|
||||
async removeMember(roomId: string, userId: string): Promise<void> {
|
||||
await api.delete(`/rooms/${roomId}/members/${userId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* Transfer ownership
|
||||
*/
|
||||
async transferOwnership(roomId: string, newOwnerId: string): Promise<void> {
|
||||
await api.post(`/rooms/${roomId}/transfer-ownership`, {
|
||||
new_owner_id: newOwnerId,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user permissions in room
|
||||
*/
|
||||
async getPermissions(roomId: string): Promise<PermissionResponse> {
|
||||
const response = await api.get<PermissionResponse>(`/rooms/${roomId}/permissions`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get room templates
|
||||
*/
|
||||
async getTemplates(): Promise<RoomTemplate[]> {
|
||||
const response = await api.get<RoomTemplate[]>('/rooms/templates')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
65
frontend/src/stores/authStore.test.ts
Normal file
65
frontend/src/stores/authStore.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useAuthStore } from './authStore'
|
||||
|
||||
describe('authStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state before each test
|
||||
useAuthStore.setState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('setAuth', () => {
|
||||
it('should set user and token', () => {
|
||||
useAuthStore.getState().setAuth('test-token-123', 'Test User', 'testuser')
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.user).toEqual({ username: 'testuser', display_name: 'Test User' })
|
||||
expect(state.token).toBe('test-token-123')
|
||||
expect(state.isAuthenticated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearAuth', () => {
|
||||
it('should clear user and token', () => {
|
||||
// First set some auth data
|
||||
useAuthStore.setState({
|
||||
user: { username: 'testuser', display_name: 'Test User' },
|
||||
token: 'test-token-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
useAuthStore.getState().clearAuth()
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.user).toBeNull()
|
||||
expect(state.token).toBeNull()
|
||||
expect(state.isAuthenticated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateUser', () => {
|
||||
it('should update user properties', () => {
|
||||
// First set some auth data
|
||||
useAuthStore.setState({
|
||||
user: { username: 'testuser', display_name: 'Test User' },
|
||||
token: 'test-token-123',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
useAuthStore.getState().updateUser({ display_name: 'Updated Name' })
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.user?.display_name).toBe('Updated Name')
|
||||
expect(state.user?.username).toBe('testuser')
|
||||
})
|
||||
|
||||
it('should not update if user is null', () => {
|
||||
useAuthStore.getState().updateUser({ display_name: 'New Name' })
|
||||
|
||||
expect(useAuthStore.getState().user).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
51
frontend/src/stores/authStore.ts
Normal file
51
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
|
||||
// Actions
|
||||
setAuth: (token: string, displayName: string, username: string) => void
|
||||
clearAuth: () => void
|
||||
updateUser: (user: Partial<User>) => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
setAuth: (token: string, displayName: string, username: string) =>
|
||||
set({
|
||||
token,
|
||||
user: { username, display_name: displayName },
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
|
||||
clearAuth: () =>
|
||||
set({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
}),
|
||||
|
||||
updateUser: (userData: Partial<User>) =>
|
||||
set((state) => ({
|
||||
user: state.user ? { ...state.user, ...userData } : null,
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
172
frontend/src/stores/chatStore.test.ts
Normal file
172
frontend/src/stores/chatStore.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useChatStore } from './chatStore'
|
||||
import type { Message } from '../types'
|
||||
|
||||
describe('chatStore', () => {
|
||||
const mockMessage: Message = {
|
||||
message_id: 'msg-1',
|
||||
room_id: 'room-1',
|
||||
sender_id: 'user-1',
|
||||
content: 'Hello, world!',
|
||||
message_type: 'text',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
sequence_number: 1,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset store state before each test
|
||||
useChatStore.setState({
|
||||
currentRoomId: null,
|
||||
messages: [],
|
||||
hasMoreMessages: true,
|
||||
connectionStatus: 'disconnected',
|
||||
typingUsers: new Set(),
|
||||
onlineUsers: new Set(),
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCurrentRoom', () => {
|
||||
it('should set current room and clear messages', () => {
|
||||
useChatStore.setState({ messages: [mockMessage] })
|
||||
|
||||
useChatStore.getState().setCurrentRoom('room-2')
|
||||
|
||||
const state = useChatStore.getState()
|
||||
expect(state.currentRoomId).toBe('room-2')
|
||||
expect(state.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('should clear typing and online users', () => {
|
||||
useChatStore.setState({
|
||||
typingUsers: new Set(['user-1']),
|
||||
onlineUsers: new Set(['user-2']),
|
||||
})
|
||||
|
||||
useChatStore.getState().setCurrentRoom('room-2')
|
||||
|
||||
const state = useChatStore.getState()
|
||||
expect(state.typingUsers.size).toBe(0)
|
||||
expect(state.onlineUsers.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setMessages', () => {
|
||||
it('should set messages array', () => {
|
||||
const messages = [mockMessage, { ...mockMessage, message_id: 'msg-2' }]
|
||||
|
||||
useChatStore.getState().setMessages(messages)
|
||||
|
||||
expect(useChatStore.getState().messages).toEqual(messages)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addMessage', () => {
|
||||
it('should add a new message', () => {
|
||||
useChatStore.getState().addMessage(mockMessage)
|
||||
|
||||
expect(useChatStore.getState().messages).toHaveLength(1)
|
||||
expect(useChatStore.getState().messages[0]).toEqual(mockMessage)
|
||||
})
|
||||
|
||||
it('should append to existing messages', () => {
|
||||
useChatStore.getState().addMessage(mockMessage)
|
||||
useChatStore.getState().addMessage({ ...mockMessage, message_id: 'msg-2' })
|
||||
|
||||
expect(useChatStore.getState().messages).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateMessage', () => {
|
||||
it('should update an existing message', () => {
|
||||
useChatStore.getState().addMessage(mockMessage)
|
||||
|
||||
useChatStore.getState().updateMessage('msg-1', { content: 'Updated content' })
|
||||
|
||||
const updatedMessage = useChatStore.getState().messages[0]
|
||||
expect(updatedMessage.content).toBe('Updated content')
|
||||
})
|
||||
|
||||
it('should not update non-existent message', () => {
|
||||
useChatStore.getState().addMessage(mockMessage)
|
||||
|
||||
useChatStore.getState().updateMessage('non-existent', { content: 'New content' })
|
||||
|
||||
expect(useChatStore.getState().messages[0].content).toBe('Hello, world!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMessage', () => {
|
||||
it('should remove a message by id', () => {
|
||||
useChatStore.getState().addMessage(mockMessage)
|
||||
|
||||
useChatStore.getState().removeMessage('msg-1')
|
||||
|
||||
expect(useChatStore.getState().messages).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setConnectionStatus', () => {
|
||||
it('should update connection status', () => {
|
||||
useChatStore.getState().setConnectionStatus('connected')
|
||||
|
||||
expect(useChatStore.getState().connectionStatus).toBe('connected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setUserTyping', () => {
|
||||
it('should add user to typing set when typing', () => {
|
||||
useChatStore.getState().setUserTyping('user-1', true)
|
||||
|
||||
expect(useChatStore.getState().typingUsers.has('user-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should remove user from typing set when not typing', () => {
|
||||
useChatStore.setState({ typingUsers: new Set(['user-1']) })
|
||||
|
||||
useChatStore.getState().setUserTyping('user-1', false)
|
||||
|
||||
expect(useChatStore.getState().typingUsers.has('user-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('online users', () => {
|
||||
it('should add online user', () => {
|
||||
useChatStore.getState().addOnlineUser('user-1')
|
||||
|
||||
expect(useChatStore.getState().onlineUsers.has('user-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should remove online user', () => {
|
||||
useChatStore.setState({ onlineUsers: new Set(['user-1']) })
|
||||
|
||||
useChatStore.getState().removeOnlineUser('user-1')
|
||||
|
||||
expect(useChatStore.getState().onlineUsers.has('user-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prependMessages', () => {
|
||||
it('should prepend messages to beginning of list', () => {
|
||||
useChatStore.getState().addMessage(mockMessage)
|
||||
const newMessage = { ...mockMessage, message_id: 'msg-0', sequence_number: 0 }
|
||||
|
||||
useChatStore.getState().prependMessages([newMessage])
|
||||
|
||||
const messages = useChatStore.getState().messages
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0].message_id).toBe('msg-0')
|
||||
expect(messages[1].message_id).toBe('msg-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMessages', () => {
|
||||
it('should clear all messages', () => {
|
||||
useChatStore.getState().addMessage(mockMessage)
|
||||
useChatStore.getState().addMessage({ ...mockMessage, message_id: 'msg-2' })
|
||||
|
||||
useChatStore.getState().clearMessages()
|
||||
|
||||
expect(useChatStore.getState().messages).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
125
frontend/src/stores/chatStore.ts
Normal file
125
frontend/src/stores/chatStore.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Message } from '../types'
|
||||
|
||||
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||
|
||||
interface ChatState {
|
||||
// Connection state
|
||||
connectionStatus: ConnectionStatus
|
||||
currentRoomId: string | null
|
||||
|
||||
// Messages
|
||||
messages: Message[]
|
||||
hasMoreMessages: boolean
|
||||
|
||||
// Typing indicators
|
||||
typingUsers: Set<string>
|
||||
|
||||
// Online users
|
||||
onlineUsers: Set<string>
|
||||
|
||||
// Actions
|
||||
setConnectionStatus: (status: ConnectionStatus) => void
|
||||
setCurrentRoom: (roomId: string | null) => void
|
||||
|
||||
// Message actions
|
||||
setMessages: (messages: Message[]) => void
|
||||
addMessage: (message: Message) => void
|
||||
updateMessage: (messageId: string, updates: Partial<Message>) => void
|
||||
removeMessage: (messageId: string) => void
|
||||
prependMessages: (messages: Message[]) => void
|
||||
setHasMoreMessages: (hasMore: boolean) => void
|
||||
clearMessages: () => void
|
||||
|
||||
// Typing actions
|
||||
setUserTyping: (userId: string, isTyping: boolean) => void
|
||||
clearTypingUsers: () => void
|
||||
|
||||
// Online users actions
|
||||
setOnlineUsers: (users: string[]) => void
|
||||
addOnlineUser: (userId: string) => void
|
||||
removeOnlineUser: (userId: string) => void
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set) => ({
|
||||
// Initial state
|
||||
connectionStatus: 'disconnected',
|
||||
currentRoomId: null,
|
||||
messages: [],
|
||||
hasMoreMessages: true,
|
||||
typingUsers: new Set(),
|
||||
onlineUsers: new Set(),
|
||||
|
||||
// Connection actions
|
||||
setConnectionStatus: (status) => set({ connectionStatus: status }),
|
||||
|
||||
setCurrentRoom: (roomId) =>
|
||||
set({
|
||||
currentRoomId: roomId,
|
||||
messages: [],
|
||||
hasMoreMessages: true,
|
||||
typingUsers: new Set(),
|
||||
onlineUsers: new Set(),
|
||||
connectionStatus: 'disconnected',
|
||||
}),
|
||||
|
||||
// Message actions
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
addMessage: (message) =>
|
||||
set((state) => ({
|
||||
messages: [...state.messages, message],
|
||||
})),
|
||||
|
||||
updateMessage: (messageId, updates) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.message_id === messageId ? { ...msg, ...updates } : msg
|
||||
),
|
||||
})),
|
||||
|
||||
removeMessage: (messageId) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.filter((msg) => msg.message_id !== messageId),
|
||||
})),
|
||||
|
||||
prependMessages: (newMessages) =>
|
||||
set((state) => ({
|
||||
messages: [...newMessages, ...state.messages],
|
||||
})),
|
||||
|
||||
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
|
||||
|
||||
clearMessages: () => set({ messages: [], hasMoreMessages: true }),
|
||||
|
||||
// Typing actions
|
||||
setUserTyping: (userId, isTyping) =>
|
||||
set((state) => {
|
||||
const newTypingUsers = new Set(state.typingUsers)
|
||||
if (isTyping) {
|
||||
newTypingUsers.add(userId)
|
||||
} else {
|
||||
newTypingUsers.delete(userId)
|
||||
}
|
||||
return { typingUsers: newTypingUsers }
|
||||
}),
|
||||
|
||||
clearTypingUsers: () => set({ typingUsers: new Set() }),
|
||||
|
||||
// Online users actions
|
||||
setOnlineUsers: (users) => set({ onlineUsers: new Set(users) }),
|
||||
|
||||
addOnlineUser: (userId) =>
|
||||
set((state) => {
|
||||
const newOnlineUsers = new Set(state.onlineUsers)
|
||||
newOnlineUsers.add(userId)
|
||||
return { onlineUsers: newOnlineUsers }
|
||||
}),
|
||||
|
||||
removeOnlineUser: (userId) =>
|
||||
set((state) => {
|
||||
const newOnlineUsers = new Set(state.onlineUsers)
|
||||
newOnlineUsers.delete(userId)
|
||||
return { onlineUsers: newOnlineUsers }
|
||||
}),
|
||||
}))
|
||||
2
frontend/src/stores/index.ts
Normal file
2
frontend/src/stores/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useAuthStore } from './authStore'
|
||||
export { useChatStore } from './chatStore'
|
||||
32
frontend/src/test/setup.ts
Normal file
32
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach, vi } from 'vitest'
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
51
frontend/src/test/test-utils.tsx
Normal file
51
frontend/src/test/test-utils.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, type RenderOptions } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
|
||||
// Create a fresh QueryClient for each test
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface WrapperProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
// Wrapper with all providers
|
||||
export function createWrapper() {
|
||||
const queryClient = createTestQueryClient()
|
||||
|
||||
return function Wrapper({ children }: WrapperProps) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
{children}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Custom render function with providers
|
||||
function customRender(
|
||||
ui: React.ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'>
|
||||
) {
|
||||
return render(ui, { wrapper: createWrapper(), ...options })
|
||||
}
|
||||
|
||||
// Re-export everything
|
||||
export * from '@testing-library/react'
|
||||
export { customRender as render }
|
||||
251
frontend/src/types/index.ts
Normal file
251
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
// Authentication Types
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
// Room Types
|
||||
export type IncidentType = 'equipment_failure' | 'material_shortage' | 'quality_issue' | 'other'
|
||||
export type SeverityLevel = 'low' | 'medium' | 'high' | 'critical'
|
||||
export type RoomStatus = 'active' | 'resolved' | 'archived'
|
||||
export type MemberRole = 'owner' | 'editor' | 'viewer'
|
||||
|
||||
export interface RoomMember {
|
||||
user_id: string
|
||||
role: MemberRole
|
||||
added_by: string
|
||||
added_at: string
|
||||
removed_at?: string | null
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
room_id: string
|
||||
title: string
|
||||
incident_type: IncidentType
|
||||
severity: SeverityLevel
|
||||
status: RoomStatus
|
||||
location?: string | null
|
||||
description?: string | null
|
||||
resolution_notes?: string | null
|
||||
created_by: string
|
||||
created_at: string
|
||||
resolved_at?: string | null
|
||||
archived_at?: string | null
|
||||
last_activity_at: string
|
||||
last_updated_at: string
|
||||
ownership_transferred_at?: string | null
|
||||
ownership_transferred_by?: string | null
|
||||
member_count: number
|
||||
members?: RoomMember[]
|
||||
current_user_role?: MemberRole | null
|
||||
is_admin_view?: boolean
|
||||
}
|
||||
|
||||
export interface CreateRoomRequest {
|
||||
title: string
|
||||
incident_type: IncidentType
|
||||
severity: SeverityLevel
|
||||
location?: string
|
||||
description?: string
|
||||
template?: string
|
||||
}
|
||||
|
||||
export interface UpdateRoomRequest {
|
||||
title?: string
|
||||
severity?: SeverityLevel
|
||||
status?: RoomStatus
|
||||
location?: string
|
||||
description?: string
|
||||
resolution_notes?: string
|
||||
}
|
||||
|
||||
export interface RoomListResponse {
|
||||
rooms: Room[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface RoomTemplate {
|
||||
template_id: number
|
||||
name: string
|
||||
description?: string
|
||||
incident_type: IncidentType
|
||||
default_severity: SeverityLevel
|
||||
default_members?: Record<string, unknown>[]
|
||||
metadata_fields?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PermissionResponse {
|
||||
role: MemberRole | null
|
||||
is_admin: boolean
|
||||
can_read: boolean
|
||||
can_write: boolean
|
||||
can_manage_members: boolean
|
||||
can_transfer_ownership: boolean
|
||||
can_update_status: boolean
|
||||
can_delete: boolean
|
||||
}
|
||||
|
||||
// Message Types
|
||||
export type MessageType = 'text' | 'image_ref' | 'file_ref' | 'system' | 'incident_data'
|
||||
|
||||
export interface Message {
|
||||
message_id: string
|
||||
room_id: string
|
||||
sender_id: string
|
||||
content: string
|
||||
message_type: MessageType
|
||||
metadata?: Record<string, unknown>
|
||||
created_at: string
|
||||
edited_at?: string | null
|
||||
deleted_at?: string | null
|
||||
sequence_number: number
|
||||
reaction_counts?: Record<string, number>
|
||||
}
|
||||
|
||||
export interface MessageListResponse {
|
||||
messages: Message[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
export interface CreateMessageRequest {
|
||||
content: string
|
||||
message_type?: MessageType
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// File Types
|
||||
export type FileType = 'image' | 'document' | 'log'
|
||||
|
||||
export interface FileMetadata {
|
||||
file_id: string
|
||||
room_id: string
|
||||
filename: string
|
||||
file_type: FileType
|
||||
mime_type: string
|
||||
file_size: number
|
||||
minio_bucket: string
|
||||
minio_object_path: string
|
||||
uploaded_at: string
|
||||
uploader_id: string
|
||||
deleted_at?: string | null
|
||||
download_url?: string
|
||||
}
|
||||
|
||||
export interface FileUploadResponse {
|
||||
file_id: string
|
||||
filename: string
|
||||
file_type: FileType
|
||||
file_size: number
|
||||
mime_type: string
|
||||
download_url: string
|
||||
uploaded_at: string
|
||||
uploader_id: string
|
||||
}
|
||||
|
||||
export interface FileListResponse {
|
||||
files: FileMetadata[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
// WebSocket Message Types
|
||||
export type WebSocketMessageType =
|
||||
| 'message'
|
||||
| 'edit_message'
|
||||
| 'delete_message'
|
||||
| 'add_reaction'
|
||||
| 'remove_reaction'
|
||||
| 'typing'
|
||||
| 'system'
|
||||
|
||||
export type SystemEventType =
|
||||
| 'user_joined'
|
||||
| 'user_left'
|
||||
| 'room_status_changed'
|
||||
| 'member_added'
|
||||
| 'member_removed'
|
||||
| 'file_uploaded'
|
||||
| 'file_deleted'
|
||||
|
||||
export interface WebSocketMessageIn {
|
||||
type: WebSocketMessageType
|
||||
content?: string
|
||||
message_type?: MessageType
|
||||
message_id?: string
|
||||
emoji?: string
|
||||
metadata?: Record<string, unknown>
|
||||
is_typing?: boolean
|
||||
}
|
||||
|
||||
export interface MessageBroadcast {
|
||||
type: 'message' | 'edit_message'
|
||||
message_id: string
|
||||
room_id: string
|
||||
sender_id: string
|
||||
content: string
|
||||
message_type: MessageType
|
||||
metadata?: Record<string, unknown>
|
||||
created_at: string
|
||||
edited_at?: string | null
|
||||
sequence_number: number
|
||||
}
|
||||
|
||||
export interface SystemBroadcast {
|
||||
type: 'system'
|
||||
event: SystemEventType
|
||||
user_id?: string
|
||||
room_id?: string
|
||||
timestamp: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface TypingBroadcast {
|
||||
type: 'typing'
|
||||
room_id: string
|
||||
user_id: string
|
||||
is_typing: boolean
|
||||
}
|
||||
|
||||
export interface FileUploadedBroadcast {
|
||||
type: 'file_uploaded'
|
||||
file_id: string
|
||||
room_id: string
|
||||
uploader_id: string
|
||||
filename: string
|
||||
file_type: string
|
||||
file_size: number
|
||||
mime_type: string
|
||||
download_url?: string
|
||||
uploaded_at: string
|
||||
}
|
||||
|
||||
export interface FileDeletedBroadcast {
|
||||
type: 'file_deleted'
|
||||
file_id: string
|
||||
room_id: string
|
||||
deleted_by: string
|
||||
deleted_at: string
|
||||
}
|
||||
|
||||
// API Error Type
|
||||
export interface ApiError {
|
||||
error: string
|
||||
detail?: string
|
||||
}
|
||||
Reference in New Issue
Block a user